Skip to content

Commit

Permalink
Fixes to user creation and name change (#660)
Browse files Browse the repository at this point in the history
* Remove beautifyDuplicateKeyError

* Translate UNIQUE_CONSTRAINT errors on user signup

* Update slug when changing name, fix permissions on username change endpoint
  • Loading branch information
goto-bus-stop authored Nov 23, 2024
1 parent c030ed0 commit 71023c6
Show file tree
Hide file tree
Showing 9 changed files with 209 additions and 96 deletions.
2 changes: 2 additions & 0 deletions locale/en.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ uwave:
banned: 'You have been banned.'
genericPermission: 'You do not have permission to do that.'
invalidEmail: The email address is not formatted correctly.
emailInUse: The email address is already in use.
invalidUsername: Invalid username. Names must be 3 to 32 characters long and must not contain spaces.
usernameInUse: The username is already in use.
recaptchaFailed: 'ReCaptcha validation failed, please try again.'
invalidResetToken: That reset token is invalid. Please double-check your reset token or request a new password reset.
incorrectPassword: The password is incorrect.
Expand Down
39 changes: 17 additions & 22 deletions src/controllers/authenticate.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ import {
InvalidResetTokenError,
UserNotFoundError,
} from '../errors/index.js';
import beautifyDuplicateKeyError from '../utils/beautifyDuplicateKeyError.js';
import toItemResponse from '../utils/toItemResponse.js';
import toListResponse from '../utils/toListResponse.js';
import { serializeUser } from '../utils/serialize.js';
Expand Down Expand Up @@ -333,30 +332,26 @@ async function register(req) {
grecaptcha, email, username, password,
} = req.body;

try {
const recaptchaOptions = req.authOptions.recaptcha;
if (recaptchaOptions && recaptchaOptions.secret) {
if (grecaptcha) {
await verifyCaptcha(grecaptcha, {
secret: recaptchaOptions.secret,
logger: req.log,
});
} else {
req.log.warn('missing client-side captcha response');
throw new ReCaptchaError();
}
const recaptchaOptions = req.authOptions.recaptcha;
if (recaptchaOptions && recaptchaOptions.secret) {
if (grecaptcha) {
await verifyCaptcha(grecaptcha, {
secret: recaptchaOptions.secret,
logger: req.log,
});
} else {
req.log.warn('missing client-side captcha response');
throw new ReCaptchaError();
}
}

const user = await users.createUser({
email,
username,
password,
});
const user = await users.createUser({
email,
username,
password,
});

return toItemResponse(serializeUser(user));
} catch (error) {
throw beautifyDuplicateKeyError(error);
}
return toItemResponse(serializeUser(user));
}

/**
Expand Down
21 changes: 10 additions & 11 deletions src/controllers/users.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import getOffsetPagination from '../utils/getOffsetPagination.js';
import toItemResponse from '../utils/toItemResponse.js';
import toListResponse from '../utils/toListResponse.js';
import toPaginatedResponse from '../utils/toPaginatedResponse.js';
import beautifyDuplicateKeyError from '../utils/beautifyDuplicateKeyError.js';
import { muteUser, unmuteUser } from './chat.js';

/**
Expand Down Expand Up @@ -163,17 +162,17 @@ async function changeUsername(req) {
const { username } = req.body;
const { users } = req.uwave;

try {
const user = await users.updateUser(
id,
{ username },
{ moderator },
);

return toItemResponse(user);
} catch (error) {
throw beautifyDuplicateKeyError(error);
if (id !== moderator.id) {
throw new PermissionError();
}

const user = await users.updateUser(
id,
{ username },
{ moderator },
);

return toItemResponse(user);
}

/**
Expand Down
14 changes: 14 additions & 0 deletions src/errors/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -145,12 +145,24 @@ const InvalidEmailError = createErrorClass('InvalidEmailError', {
base: UnprocessableEntity,
});

const UsedEmailError = createErrorClass('UsedEmailError', {
code: 'invalid-email',
string: 'errors.emailInUse',
base: UnprocessableEntity,
});

const InvalidUsernameError = createErrorClass('InvalidUsernameError', {
code: 'invalid-username',
string: 'errors.invalidUsername',
base: UnprocessableEntity,
});

const UsedUsernameError = createErrorClass('UsedUsernameError', {
code: 'invalid-username',
string: 'errors.usernameInUse',
base: UnprocessableEntity,
});

const ReCaptchaError = createErrorClass('ReCaptchaError', {
code: 'recaptcha-failed',
string: 'errors.recaptchaFailed',
Expand Down Expand Up @@ -269,7 +281,9 @@ export {
RateLimitError,
NameChangeRateLimitError,
InvalidEmailError,
UsedEmailError,
InvalidUsernameError,
UsedUsernameError,
InvalidResetTokenError,
ReCaptchaError,
IncorrectPasswordError,
Expand Down
64 changes: 44 additions & 20 deletions src/plugins/users.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { randomUUID } from 'crypto';
import lodash from 'lodash';
import { sql } from 'kysely';
import { slugify } from 'transliteration';
import bcrypt from 'bcryptjs';
import Page from '../Page.js';
import { IncorrectPasswordError, UserNotFoundError } from '../errors/index.js';
import { slugify } from 'transliteration';
import {
IncorrectPasswordError, UsedEmailError, UsedUsernameError, UserNotFoundError,
} from '../errors/index.js';
import { jsonGroupArray } from '../utils/sqlite.js';
import { sql } from 'kysely';
import { randomUUID } from 'crypto';

const { pick, omit } = lodash;

Expand All @@ -31,6 +33,24 @@ const avatarColumn = (eb) => eb.fn.coalesce(
/** @type {import('kysely').RawBuilder<string>} */ (sql`concat('https://sigil.u-wave.net/', ${eb.ref('users.id')})`),
);

/**
* Translate a SQLite error into a HTTP error explaining the problem.
*
* @param {unknown} err
* @returns {never}
*/
function rethrowInsertError(err) {
if (err instanceof Error && 'code' in err && err.code === 'SQLITE_CONSTRAINT_UNIQUE') {
if (err.message.includes('users.email')) {
throw new UsedEmailError();
}
if (err.message.includes('users.username') || err.message.includes('users.slug')) {
throw new UsedUsernameError();
}
}
throw err;
}

class UsersRepository {
#uw;

Expand Down Expand Up @@ -290,7 +310,7 @@ class UsersRepository {

return user;
}
});
}).catch(rethrowInsertError);

return user;
}
Expand All @@ -307,7 +327,7 @@ class UsersRepository {

const hash = await encryptPassword(password);

const user = await db.insertInto('users')
const insert = db.insertInto('users')
.values({
id: /** @type {UserID} */ (randomUUID()),
username,
Expand All @@ -325,8 +345,14 @@ class UsersRepository {
'users.pendingActivation',
'users.createdAt',
'users.updatedAt',
])
.executeTakeFirstOrThrow();
]);

let user;
try {
user = await insert.executeTakeFirstOrThrow();
} catch (err) {
rethrowInsertError(err);
}

const roles = ['user'];
await acl.allow(user, roles);
Expand Down Expand Up @@ -380,29 +406,27 @@ class UsersRepository {
});
Object.assign(user, update);

const derivedUpdates = {};
if ('username' in update && update.username != null) {
derivedUpdates.slug = slugify(update.username);
}

const updatesFromDatabase = await db.updateTable('users')
.where('id', '=', id)
.set(update)
.returning(['username'])
.executeTakeFirst();
.set({ ...update, ...derivedUpdates })
.returning(['username', 'slug'])
.executeTakeFirst()
.catch(rethrowInsertError);
if (!updatesFromDatabase) {
throw new UserNotFoundError({ id });
}
Object.assign(user, updatesFromDatabase);

// Take updated keys from the Model again,
// as it may apply things like Unicode normalization on the values.
Object.keys(update).forEach((key) => {
// @ts-expect-error Infeasible to statically check properties here
// Hopefully the caller took care
update[key] = user[key];
});

this.#uw.publish('user:update', {
userID: user.id,
moderatorID: moderator ? moderator.id : null,
old,
new: update,
new: updatesFromDatabase,
});

return user;
Expand Down
3 changes: 2 additions & 1 deletion src/routes/users.js
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ function userRoutes() {
// PUT /users/:id/username - Change a user's username.
.put(
'/:id/username',
protect(),
schema(validations.setUserName),
rateLimit('change-username', {
max: 5,
Expand All @@ -68,7 +69,7 @@ function userRoutes() {
}),
route(controller.changeUsername),
)
// PUT /users/:id/avatar - Change a user's username.
// PUT /users/:id/avatar - Change a user's avatar.
.put(
'/:id/avatar',
protect(),
Expand Down
36 changes: 0 additions & 36 deletions src/utils/beautifyDuplicateKeyError.js

This file was deleted.

Loading

0 comments on commit 71023c6

Please sign in to comment.