Skip to content

Commit

Permalink
feat: added DELETE /api/v1/users/:uid and DELETE /api/v1/users
Browse files Browse the repository at this point in the history
  • Loading branch information
julianlam committed Oct 8, 2020
1 parent d15d9e4 commit a1ddc21
Show file tree
Hide file tree
Showing 12 changed files with 431 additions and 169 deletions.
350 changes: 227 additions & 123 deletions openapi.yaml

Large diffs are not rendered by default.

18 changes: 10 additions & 8 deletions public/src/client/account/edit.js
Expand Up @@ -42,18 +42,20 @@ define('forum/account/edit', ['forum/account/header', 'translator', 'components'

$(window).trigger('action:profile.update', userData);

socket.emit('user.updateProfile', userData, function (err, data) {
if (err) {
return app.alertError(err.message);
}

$.ajax({
url: config.relative_path + '/api/v1/users/' + userData.uid,
data: userData,
method: 'put',
}).done(function (res) {
app.alertSuccess('[[user:profile_update_success]]');

if (data.picture) {
$('#user-current-picture').attr('src', data.picture);
if (res.response.picture) {
$('#user-current-picture').attr('src', res.response.picture);
}

updateHeader(data.picture);
updateHeader(res.response.picture);
}).fail(function (ev) {
return app.alertError(ev.responseJSON.status.message);
});

return false;
Expand Down
14 changes: 8 additions & 6 deletions public/src/client/account/edit/email.js
Expand Up @@ -27,13 +27,15 @@ define('forum/account/edit/email', ['forum/account/header'], function (header) {
var btn = $(this);
btn.addClass('disabled').find('i').removeClass('hide');

socket.emit('user.changeUsernameEmail', userData, function (err) {
$.ajax({
url: config.relative_path + '/api/v1/users/' + userData.uid,
data: userData,
method: 'put',
}).done(function (res) {
btn.removeClass('disabled').find('i').addClass('hide');
if (err) {
return app.alertError(err.message);
}

ajaxify.go('user/' + ajaxify.data.userslug + '/edit');
ajaxify.go('user/' + res.response.userslug + '/edit');
}).fail(function (ev) {
app.alertError(ev.responseJSON.status.message);
});

return false;
Expand Down
15 changes: 9 additions & 6 deletions public/src/client/account/edit/username.js
Expand Up @@ -24,22 +24,25 @@ define('forum/account/edit/username', ['forum/account/header'], function (header

var btn = $(this);
btn.addClass('disabled').find('i').removeClass('hide');
socket.emit('user.changeUsernameEmail', userData, function (err, data) {
btn.removeClass('disabled').find('i').addClass('hide');
if (err) {
return app.alertError(err.message);
}

$.ajax({
url: config.relative_path + '/api/v1/users/' + userData.uid,
data: userData,
method: 'put',
}).done(function (res) {
btn.removeClass('disabled').find('i').addClass('hide');
var userslug = utils.slugify(userData.username);
if (userData.username && userslug && parseInt(userData.uid, 10) === parseInt(app.user.uid, 10)) {
$('[component="header/profilelink"]').attr('href', config.relative_path + '/user/' + userslug);
$('[component="header/profilelink/edit"]').attr('href', config.relative_path + '/user/' + userslug + '/edit');
$('[component="header/profilelink/settings"]').attr('href', config.relative_path + '/user/' + userslug + '/settings');
$('[component="header/username"]').text(userData.username);
$('[component="header/usericon"]').css('background-color', data['icon:bgColor']).text(data['icon:text']);
$('[component="header/usericon"]').css('background-color', res.response['icon:bgColor']).text(res.response['icon:text']);
}

ajaxify.go('user/' + userslug + '/edit');
}).fail(function (ev) {
app.alertError(ev.responseJSON.status.message);
});

return false;
Expand Down
10 changes: 7 additions & 3 deletions src/controllers/helpers.js
Expand Up @@ -349,12 +349,16 @@ helpers.formatApiResponse = async (statusCode, res, payload) => {
response: payload || {},
});
} else if (payload instanceof Error) {
let message = '';
if (isLanguageKey.test(payload.message)) {
const translated = await translator.translate(payload.message, 'en-GB');
res.status(statusCode).json(helpers.generateError(statusCode, translated));
message = await translator.translate(payload.message, 'en-GB');
} else {
res.status(statusCode).json(helpers.generateError(statusCode, payload.message));
message = payload.message;
}

const returnPayload = helpers.generateError(statusCode, message);
returnPayload.stack = payload.stack;
res.status(statusCode).json(returnPayload);
} else if (!payload) {
// Non-2xx statusCode, generate predefined error
res.status(statusCode).json(helpers.generateError(statusCode));
Expand Down
103 changes: 100 additions & 3 deletions src/controllers/write/users.js
@@ -1,11 +1,108 @@
'use strict';

const users = require('../../user');
const user = require('../../user');
const groups = require('../../groups');
const privileges = require('../../privileges');
const meta = require('../../meta');
const events = require('../../events');
const helpers = require('../helpers');

const Users = module.exports;

Users.create = async (req, res) => {
const uid = await users.create(req.body);
helpers.formatApiResponse(200, res, await users.getUserData(uid));
const uid = await user.create(req.body);
helpers.formatApiResponse(200, res, await user.getUserData(uid));
};

Users.update = async (req, res) => {
const oldUserData = await user.getUserFields(req.params.uid, ['email', 'username']);
if (!oldUserData || !oldUserData.username) {
throw new Error('[[error:invalid-data]]');
}

const [isAdminOrGlobalMod, canEdit, passwordMatch] = await Promise.all([
user.isAdminOrGlobalMod(req.user.uid),
privileges.users.canEdit(req.user.uid, req.params.uid),
user.isPasswordCorrect(req.body.uid, req.body.password, req.ip),
]);

// Changing own email/username requires password confirmation
if (req.user.uid === req.body.uid && !passwordMatch) {
helpers.formatApiResponse(403, res, new Error('[[error:invalid-password]]'));
}

if (!canEdit) {
helpers.formatApiResponse(403, res, new Error('[[error:no-privileges]]'));
}

if (!isAdminOrGlobalMod && meta.config['username:disableEdit']) {
req.body.username = oldUserData.username;
}

if (!isAdminOrGlobalMod && meta.config['email:disableEdit']) {
req.body.email = oldUserData.email;
}

req.body.uid = req.params.uid; // The `uid` argument in `updateProfile` refers to calling user, not target user
await user.updateProfile(req.user.uid, req.body);
const userData = await user.getUserData(req.body.uid);

async function log(type, eventData) {
eventData.type = type;
eventData.uid = req.user.uid;
eventData.targetUid = req.params.uid;
eventData.ip = req.ip;
await events.log(eventData);
}

if (userData.email !== oldUserData.email) {
await log('email-change', { oldEmail: oldUserData.email, newEmail: userData.email });
}

if (userData.username !== oldUserData.username) {
await log('username-change', { oldUsername: oldUserData.username, newUsername: userData.username });
}

helpers.formatApiResponse(200, res, userData);
};

Users.delete = async (req, res) => {
processDeletion(req.params.uid, req, res);
helpers.formatApiResponse(200, res);
};

Users.deleteMany = async (req, res) => {
await canDeleteUids(req.body.uids, res);
await Promise.all(req.body.uids.map(uid => processDeletion(uid, req, res)));
helpers.formatApiResponse(200, res);
};

async function canDeleteUids(uids, res) {
if (!Array.isArray(uids)) {
helpers.formatApiResponse(400, res, new Error('[[error:invalid-data]]'));
}
const isMembers = await groups.isMembers(uids, 'administrators');
if (isMembers.includes(true)) {
helpers.formatApiResponse(403, res, new Error('[[error:cant-delete-other-admins]]'));
}
}

async function processDeletion(uid, req, res) {
const isTargetAdmin = await user.isAdministrator(uid);
if (!res.locals.privileges.isSelf && !res.locals.privileges.isAdmin) {
return helpers.formatApiResponse(403, res);
} else if (!res.locals.privileges.isSelf && isTargetAdmin) {
return helpers.formatApiResponse(403, res, new Error('[[error:cant-delete-other-admins]]'));
}

// TODO: clear user tokens for this uid
const userData = await user.delete(req.user.uid, uid);
await events.log({
type: 'user-delete',
uid: req.user.uid,
targetUid: uid,
ip: req.ip,
username: userData.username,
email: userData.email,
});
}
40 changes: 40 additions & 0 deletions src/middleware/expose.js
@@ -0,0 +1,40 @@
'use strict';

/**
* The middlewares here strictly act to "expose" certain values from the database,
* into `res.locals` for use in middlewares and/or controllers down the line
*/

const user = require('../user');
const utils = require('../utils');

module.exports = function (middleware) {
middleware.exposeAdmin = async (req, res, next) => {
// Unlike `requireAdmin`, this middleware just checks the uid, and sets `isAdmin` in `res.locals`
res.locals.isAdmin = false;

if (!req.user) {
return next();
}

const isAdmin = await user.isAdministrator(req.user.uid);
res.locals.isAdmin = isAdmin;
return next();
};

middleware.exposePrivileges = async (req, res, next) => {
// Exposes a hash of user's ranks (admin, gmod, etc.)
const hash = await utils.promiseParallel({
isAdmin: user.isAdministrator(req.user.uid),
isGmod: user.isGlobalModerator(req.user.uid),
isPrivileged: user.isPrivileged(req.user.uid),
});

if (req.params.uid) {
hash.isSelf = parseInt(req.params.uid, 10) === req.user.uid;
}

res.locals.privileges = hash;
return next();
};
};
1 change: 1 addition & 0 deletions src/middleware/index.js
Expand Up @@ -59,6 +59,7 @@ require('./render')(middleware);
require('./maintenance')(middleware);
require('./user')(middleware);
require('./headers')(middleware);
require('./expose')(middleware);

middleware.stripLeadingSlashes = function stripLeadingSlashes(req, res, next) {
var target = req.originalUrl.replace(nconf.get('relative_path'), '');
Expand Down
7 changes: 4 additions & 3 deletions src/routes/helpers.js
@@ -1,6 +1,7 @@
'use strict';

var helpers = module.exports;
const helpers = module.exports;
const controllerHelpers = require('../controllers/helpers');

helpers.setupPageRoute = function (router, name, middleware, middlewares, controller) {
middlewares = [middleware.maintenanceMode, middleware.registrationComplete, middleware.pageView, middleware.pluginHooks].concat(middlewares);
Expand All @@ -15,8 +16,8 @@ helpers.setupAdminPageRoute = function (router, name, middleware, middlewares, c
};

helpers.setupApiRoute = function (router, name, middleware, middlewares, verb, controller) {
router[verb](name, middleware.authenticate, middlewares, helpers.tryRoute(controller, (err, res) => {
helpers.formatApiResponse(400, res, err);
router[verb](name, middlewares, helpers.tryRoute(controller, (err, res) => {
controllerHelpers.formatApiResponse(400, res, err);
}));
};

Expand Down
35 changes: 18 additions & 17 deletions src/routes/write/users.js
@@ -1,35 +1,32 @@
'use strict';

const router = require('express').Router();
const middleware = require('../../middleware');
const controllers = require('../../controllers');
const routeHelpers = require('../../routes/helpers');
const routeHelpers = require('../helpers');

const setupApiRoute = routeHelpers.setupApiRoute;
// Messaging = require.main.require('./src/messaging'),
// apiMiddleware = require('./middleware'),
// errorHandler = require('../../lib/errorHandler'),
// auth = require('../../lib/auth'),
// utils = require('./utils'),
// async = require.main.require('async');

// eslint-disable-next-line no-unused-vars
function guestRoutes() {
// like registration, login...
}

module.exports = function () {
const router = require('express').Router();
const setupApiRoute = routeHelpers.setupApiRoute;
function authenticatedRoutes() {
const middlewares = [middleware.authenticate];

setupApiRoute(router, '/', middleware, [middleware.checkRequired.bind(null, ['username']), middleware.isAdmin], 'post', controllers.write.users.create);
setupApiRoute(router, '/', middleware, [...middlewares, middleware.checkRequired.bind(null, ['username']), middleware.isAdmin], 'post', controllers.write.users.create);
setupApiRoute(router, '/', middleware, [...middlewares, middleware.checkRequired.bind(null, ['uids']), middleware.isAdmin, middleware.exposePrivileges], 'delete', controllers.write.users.deleteMany);
setupApiRoute(router, '/:uid', middleware, [...middlewares], 'put', controllers.write.users.update);
setupApiRoute(router, '/:uid', middleware, [...middlewares, middleware.exposePrivileges], 'delete', controllers.write.users.delete);

// app.route('/:uid')
// .put(apiMiddleware.requireUser, apiMiddleware.exposeAdmin, function(req, res) {
// if (parseInt(req.params.uid, 10) !== parseInt(req.user.uid, 10) && !res.locals.isAdmin) {
// return errorHandler.respond(401, res);
// }

// // `uid` in `updateProfile` refers to calling user, not target user
// req.body.uid = req.params.uid;

// Users.updateProfile(req.user.uid, req.body, function(err) {
// return errorHandler.handle(err, res);
// });
// })
// .delete(apiMiddleware.requireUser, apiMiddleware.exposeAdmin, function(req, res) {
// if (parseInt(req.params.uid, 10) !== parseInt(req.user.uid, 10) && !res.locals.isAdmin) {
// return errorHandler.respond(401, res);
Expand Down Expand Up @@ -162,6 +159,10 @@ module.exports = function () {
// errorHandler.handle(err, res);
// });
// });
}

module.exports = function () {
authenticatedRoutes();

return router;
};
2 changes: 2 additions & 0 deletions src/socket.io/admin/user.js
Expand Up @@ -139,6 +139,8 @@ User.deleteUsersContent = async function (socket, uids) {
};

User.deleteUsersAndContent = async function (socket, uids) {
sockets.warnDeprecated(socket, 'DELETE /api/v1/users or DELETE /api/v1/users/:uid');

await canDeleteUids(uids);
deleteUsers(socket, uids, async function (uid) {
return await user.delete(socket.uid, uid);
Expand Down
5 changes: 5 additions & 0 deletions src/socket.io/user/profile.js
Expand Up @@ -9,9 +9,12 @@ const privileges = require('../../privileges');
const notifications = require('../../notifications');
const db = require('../../database');
const plugins = require('../../plugins');
const sockets = require('..');

module.exports = function (SocketUser) {
SocketUser.changeUsernameEmail = async function (socket, data) {
sockets.warnDeprecated(socket, 'PUT /api/v1/users/:uid');

if (!data || !data.uid || !socket.uid) {
throw new Error('[[error:invalid-data]]');
}
Expand Down Expand Up @@ -92,6 +95,8 @@ module.exports = function (SocketUser) {
};

SocketUser.updateProfile = async function (socket, data) {
sockets.warnDeprecated(socket, 'PUT /api/v1/users/:uid');

if (!socket.uid) {
throw new Error('[[error:invalid-uid]]');
}
Expand Down

0 comments on commit a1ddc21

Please sign in to comment.