All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m46s
209 lines
6.0 KiB
JavaScript
209 lines
6.0 KiB
JavaScript
const crypto = require('crypto');
|
|
const { pool } = require('../db');
|
|
|
|
const HASH_ALGORITHM = 'scrypt';
|
|
const KEY_LENGTH = 64;
|
|
|
|
const normalizeEmail = (email) => String(email || '').trim().toLowerCase();
|
|
|
|
const generatePassword = () => {
|
|
return crypto.randomBytes(9).toString('base64url');
|
|
};
|
|
|
|
const hashPassword = (password) => {
|
|
const salt = crypto.randomBytes(16).toString('hex');
|
|
const hash = crypto.scryptSync(password, salt, KEY_LENGTH).toString('hex');
|
|
return `${HASH_ALGORITHM}:${salt}:${hash}`;
|
|
};
|
|
|
|
const verifyPassword = (password, passwordHash) => {
|
|
const [algorithm, salt, storedHash] = String(passwordHash || '').split(':');
|
|
if (algorithm !== HASH_ALGORITHM || !salt || !storedHash) return false;
|
|
|
|
const hash = crypto.scryptSync(password, salt, KEY_LENGTH);
|
|
const storedBuffer = Buffer.from(storedHash, 'hex');
|
|
if (storedBuffer.length !== hash.length) return false;
|
|
|
|
return crypto.timingSafeEqual(hash, storedBuffer);
|
|
};
|
|
|
|
const publicUserFields = (row) => ({
|
|
id: row.id,
|
|
name: row.name,
|
|
email: row.email,
|
|
isActive: row.is_active,
|
|
createdAt: row.created_at,
|
|
updatedAt: row.updated_at
|
|
});
|
|
|
|
const findUserByEmail = async (email) => {
|
|
const normalizedEmail = normalizeEmail(email);
|
|
const result = await pool.query(
|
|
`SELECT id, name, email, password_hash, is_active, created_at, updated_at
|
|
FROM app_users
|
|
WHERE LOWER(email) = $1
|
|
LIMIT 1`,
|
|
[normalizedEmail]
|
|
);
|
|
|
|
return result.rows[0] || null;
|
|
};
|
|
|
|
const listUsers = async () => {
|
|
const result = await pool.query(
|
|
`SELECT id, name, email, is_active, created_at, updated_at
|
|
FROM app_users
|
|
ORDER BY created_at DESC, id DESC`
|
|
);
|
|
|
|
return result.rows.map(publicUserFields);
|
|
};
|
|
|
|
const createUser = async ({ name, email, password }) => {
|
|
const normalizedName = String(name || '').trim();
|
|
const normalizedEmail = normalizeEmail(email);
|
|
const generatedPassword = !password;
|
|
const plainPassword = String(password || generatePassword()).trim();
|
|
|
|
if (!normalizedName) {
|
|
const error = new Error('Name is required');
|
|
error.statusCode = 400;
|
|
throw error;
|
|
}
|
|
|
|
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(normalizedEmail)) {
|
|
const error = new Error('Valid email is required');
|
|
error.statusCode = 400;
|
|
throw error;
|
|
}
|
|
|
|
if (plainPassword.length < 6) {
|
|
const error = new Error('Password must have at least 6 characters');
|
|
error.statusCode = 400;
|
|
throw error;
|
|
}
|
|
|
|
const passwordHash = hashPassword(plainPassword);
|
|
|
|
try {
|
|
const result = await pool.query(
|
|
`INSERT INTO app_users (name, email, password_hash)
|
|
VALUES ($1, $2, $3)
|
|
RETURNING id, name, email, is_active, created_at, updated_at`,
|
|
[normalizedName, normalizedEmail, passwordHash]
|
|
);
|
|
|
|
return {
|
|
user: publicUserFields(result.rows[0]),
|
|
password: plainPassword,
|
|
generatedPassword
|
|
};
|
|
} catch (error) {
|
|
if (error.code === '23505') {
|
|
const duplicateError = new Error('A user with this email already exists');
|
|
duplicateError.statusCode = 409;
|
|
throw duplicateError;
|
|
}
|
|
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
const findUserById = async (id) => {
|
|
const result = await pool.query(
|
|
`SELECT id, name, email, password_hash, is_active, created_at, updated_at
|
|
FROM app_users
|
|
WHERE id = $1
|
|
LIMIT 1`,
|
|
[id]
|
|
);
|
|
|
|
return result.rows[0] || null;
|
|
};
|
|
|
|
const updateUser = async (id, { name, email, password, isActive }) => {
|
|
const existingUser = await findUserById(id);
|
|
if (!existingUser) {
|
|
const error = new Error('User not found');
|
|
error.statusCode = 404;
|
|
throw error;
|
|
}
|
|
|
|
const normalizedName = String(name ?? existingUser.name).trim();
|
|
const normalizedEmail = normalizeEmail(email ?? existingUser.email);
|
|
const normalizedIsActive = typeof isActive === 'boolean' ? isActive : existingUser.is_active;
|
|
const normalizedPassword = typeof password === 'string' ? password.trim() : '';
|
|
|
|
if (!normalizedName) {
|
|
const error = new Error('Name is required');
|
|
error.statusCode = 400;
|
|
throw error;
|
|
}
|
|
|
|
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(normalizedEmail)) {
|
|
const error = new Error('Valid email is required');
|
|
error.statusCode = 400;
|
|
throw error;
|
|
}
|
|
|
|
if (normalizedPassword && normalizedPassword.length < 6) {
|
|
const error = new Error('Password must have at least 6 characters');
|
|
error.statusCode = 400;
|
|
throw error;
|
|
}
|
|
|
|
const passwordHash = normalizedPassword ? hashPassword(normalizedPassword) : existingUser.password_hash;
|
|
|
|
try {
|
|
const result = await pool.query(
|
|
`UPDATE app_users
|
|
SET name = $1,
|
|
email = $2,
|
|
password_hash = $3,
|
|
is_active = $4,
|
|
updated_at = CURRENT_TIMESTAMP
|
|
WHERE id = $5
|
|
RETURNING id, name, email, is_active, created_at, updated_at`,
|
|
[normalizedName, normalizedEmail, passwordHash, normalizedIsActive, id]
|
|
);
|
|
|
|
return publicUserFields(result.rows[0]);
|
|
} catch (error) {
|
|
if (error.code === '23505') {
|
|
const duplicateError = new Error('A user with this email already exists');
|
|
duplicateError.statusCode = 409;
|
|
throw duplicateError;
|
|
}
|
|
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
const deleteUser = async (id) => {
|
|
const result = await pool.query(
|
|
`DELETE FROM app_users
|
|
WHERE id = $1
|
|
RETURNING id`,
|
|
[id]
|
|
);
|
|
|
|
if (!result.rowCount) {
|
|
const error = new Error('User not found');
|
|
error.statusCode = 404;
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
module.exports = {
|
|
createUser,
|
|
deleteUser,
|
|
findUserById,
|
|
findUserByEmail,
|
|
generatePassword,
|
|
listUsers,
|
|
normalizeEmail,
|
|
publicUserFields,
|
|
updateUser,
|
|
verifyPassword
|
|
};
|