Updated profile and post capabilities
This commit is contained in:
@@ -36,16 +36,36 @@ function signToken(userId) {
|
||||
return { token: `${content}.${signature}`, expiresAt: payload.exp };
|
||||
}
|
||||
|
||||
function authenticateToken(req, res, next) {
|
||||
function getTokenFromRequest(req) {
|
||||
const authorization = req.get('authorization') || '';
|
||||
const match = authorization.match(/^Bearer\s+([^\s]+)$/i);
|
||||
if (match) {
|
||||
return match[1];
|
||||
}
|
||||
|
||||
if (!match) {
|
||||
return res.status(401).send('A valid Bearer token is required');
|
||||
const cookieHeader = req.get('cookie') || '';
|
||||
if (cookieHeader) {
|
||||
const cookies = cookieHeader.split(';');
|
||||
for (const cookie of cookies) {
|
||||
const [name, ...rest] = cookie.trim().split('=');
|
||||
if (name === 'fc_session_token') {
|
||||
return rest.join('=');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function authenticateToken(req, res, next) {
|
||||
const token = getTokenFromRequest(req);
|
||||
|
||||
if (!token) {
|
||||
return res.status(401).send('A valid Bearer token or session cookie is required');
|
||||
}
|
||||
|
||||
try {
|
||||
const [encodedHeader, encodedPayload, providedSignature] = match[1].split('.');
|
||||
const [encodedHeader, encodedPayload, providedSignature] = token.split('.');
|
||||
if (!encodedHeader || !encodedPayload || !providedSignature) {
|
||||
throw new Error('Malformed token');
|
||||
}
|
||||
|
||||
@@ -34,7 +34,9 @@ const uploadImage = multer({
|
||||
},
|
||||
}).single("image");
|
||||
|
||||
function handlePostUpload(req, res, next) {
|
||||
const ALLOWED_IMAGE_PREFIXES = ["/posts/image/", "/profiles/image/"];
|
||||
|
||||
function handleImageUpload(req, res, next) {
|
||||
uploadImage(req, res, (err) => {
|
||||
if (!err) return next();
|
||||
if (err.code === "LIMIT_FILE_SIZE") {
|
||||
@@ -44,26 +46,47 @@ function handlePostUpload(req, res, next) {
|
||||
});
|
||||
}
|
||||
|
||||
function imageLinkForFile(file) {
|
||||
return file ? `/posts/image/${file.filename}` : null;
|
||||
function handlePostUpload(req, res, next) {
|
||||
return handleImageUpload(req, res, next);
|
||||
}
|
||||
|
||||
function handleProfileUpload(req, res, next) {
|
||||
return handleImageUpload(req, res, next);
|
||||
}
|
||||
|
||||
function imageLinkForFile(file, prefix = "/posts/image/") {
|
||||
return file ? `${prefix}${file.filename}` : null;
|
||||
}
|
||||
|
||||
function profileImageLinkForFile(file) {
|
||||
return imageLinkForFile(file, "/profiles/image/");
|
||||
}
|
||||
|
||||
async function removeImageLink(imageLink) {
|
||||
if (!imageLink || !imageLink.startsWith("/posts/image/")) return;
|
||||
if (!imageLink || !ALLOWED_IMAGE_PREFIXES.some((prefix) => imageLink.startsWith(prefix))) {
|
||||
return;
|
||||
}
|
||||
const filename = path.basename(imageLink);
|
||||
await fs.promises.unlink(path.join(uploadDirectory, filename)).catch((err) => {
|
||||
if (err.code !== "ENOENT") throw err;
|
||||
});
|
||||
}
|
||||
|
||||
async function removeUploadedProfileFile(file) {
|
||||
if (file) await removeImageLink(profileImageLinkForFile(file));
|
||||
}
|
||||
|
||||
async function removeUploadedFile(file) {
|
||||
if (file) await removeImageLink(imageLinkForFile(file));
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
handlePostUpload,
|
||||
handleProfileUpload,
|
||||
imageLinkForFile,
|
||||
profileImageLinkForFile,
|
||||
removeImageLink,
|
||||
removeUploadedFile,
|
||||
removeUploadedProfileFile,
|
||||
uploadDirectory,
|
||||
};
|
||||
|
||||
@@ -57,45 +57,149 @@ function validateCommonFields(values, { requireName = true } = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
function validateRegistrationInput(values) {
|
||||
validateCommonFields(values);
|
||||
function validatePasswordRules(password, { username, email }) {
|
||||
if (!password || password.length > LIMITS.password) {
|
||||
throw new Error(`password must be between 1 and ${LIMITS.password} characters`);
|
||||
}
|
||||
|
||||
if (values.password.length < MIN_PASSWORD_LENGTH) {
|
||||
if (password.length < MIN_PASSWORD_LENGTH) {
|
||||
throw new Error(`password must be at least ${MIN_PASSWORD_LENGTH} characters`);
|
||||
}
|
||||
|
||||
const passwordLower = values.password.toLowerCase();
|
||||
const passwordLower = password.toLowerCase();
|
||||
const normalizedPassword = passwordLower.replace(/[^a-z0-9]/g, '');
|
||||
const emailLocalPart = values.email.split('@')[0];
|
||||
const emailLocalPart = email.split('@')[0];
|
||||
if (
|
||||
COMMON_PASSWORDS.has(normalizedPassword) ||
|
||||
passwordLower.includes(values.username.toLowerCase()) ||
|
||||
passwordLower.includes(username.toLowerCase()) ||
|
||||
passwordLower.includes(emailLocalPart)
|
||||
) {
|
||||
throw new Error('password is too common or contains account details');
|
||||
}
|
||||
|
||||
if (!/[a-z]/.test(values.password) || !/[A-Z]/.test(values.password)) {
|
||||
if (!/[a-z]/.test(password) || !/[A-Z]/.test(password)) {
|
||||
throw new Error('password must contain lowercase and uppercase letters');
|
||||
}
|
||||
|
||||
if (!/[0-9]/.test(values.password)) {
|
||||
if (!/[0-9]/.test(password)) {
|
||||
throw new Error('password must contain a number');
|
||||
}
|
||||
|
||||
if (!/[^a-zA-Z0-9\s]/.test(values.password)) {
|
||||
if (!/[^a-zA-Z0-9\s]/.test(password)) {
|
||||
throw new Error('password must contain a special character');
|
||||
}
|
||||
|
||||
if (/(.)\1\1/.test(values.password) || /(0123|1234|2345|3456|4567|5678|6789|abcd|bcde|cdef)/i.test(values.password)) {
|
||||
if (/(.)\1\1/.test(password) || /(0123|1234|2345|3456|4567|5678|6789|abcd|bcde|cdef)/i.test(password)) {
|
||||
throw new Error('password must not contain obvious repeated or sequential characters');
|
||||
}
|
||||
}
|
||||
|
||||
function validateRegistrationInput(values) {
|
||||
validateCommonFields(values);
|
||||
validatePasswordRules(values.password, {
|
||||
username: values.username,
|
||||
email: values.email,
|
||||
});
|
||||
|
||||
if (isDisposableEmail(values.email)) {
|
||||
throw new Error('temporary email addresses are not allowed');
|
||||
}
|
||||
}
|
||||
|
||||
function cleanProfileUpdateInput(input) {
|
||||
const body = input || {};
|
||||
const values = {};
|
||||
|
||||
for (const field of ['name', 'username', 'email', 'password', 'current_password']) {
|
||||
if (body[field] !== undefined) {
|
||||
const preserveWhitespace = field === 'password' || field === 'current_password';
|
||||
let value = cleanText(body[field], { trim: !preserveWhitespace });
|
||||
if (field === 'email') {
|
||||
value = value.toLowerCase();
|
||||
}
|
||||
values[field] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return values;
|
||||
}
|
||||
|
||||
function validateOptionalPersonField(field, value) {
|
||||
if (!value || value.length > LIMITS[field]) {
|
||||
throw new Error(`${field} must be between 1 and ${LIMITS[field]} characters`);
|
||||
}
|
||||
|
||||
if (field === 'username' && /\s/.test(value)) {
|
||||
throw new Error('username must not contain spaces');
|
||||
}
|
||||
|
||||
if (field === 'email') {
|
||||
if (/\s/.test(value)) {
|
||||
throw new Error('email must not contain spaces');
|
||||
}
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
|
||||
throw new Error('email must be a valid email address');
|
||||
}
|
||||
if (isDisposableEmail(value)) {
|
||||
throw new Error('temporary email addresses are not allowed');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function validateProfileUpdateInput(values, { hasImageChange = false } = {}) {
|
||||
const changeFields = ['name', 'username', 'email', 'password'].filter((field) => values[field] !== undefined);
|
||||
if (changeFields.length === 0 && !hasImageChange) {
|
||||
throw new Error('At least one profile field must be changed');
|
||||
}
|
||||
|
||||
if (values.password !== undefined && values.current_password === undefined) {
|
||||
throw new Error('current_password is required when changing password');
|
||||
}
|
||||
if (values.current_password !== undefined && values.password === undefined) {
|
||||
throw new Error('password is required when submitting current_password');
|
||||
}
|
||||
|
||||
for (const field of ['name', 'username', 'email']) {
|
||||
if (values[field] !== undefined) {
|
||||
validateOptionalPersonField(field, values[field]);
|
||||
}
|
||||
}
|
||||
|
||||
if (values.current_password !== undefined && (
|
||||
!values.current_password || values.current_password.length > LIMITS.password
|
||||
)) {
|
||||
throw new Error(`current_password must be between 1 and ${LIMITS.password} characters`);
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeProfileUpdate(req, res, next) {
|
||||
try {
|
||||
const values = cleanProfileUpdateInput(req.body);
|
||||
const removeImageValue = req.body?.remove_image;
|
||||
const hasImageChange = Boolean(req.file) || removeImageValue === 'true';
|
||||
validateProfileUpdateInput(values, { hasImageChange });
|
||||
req.profileUpdate = values;
|
||||
next();
|
||||
}
|
||||
catch (err) {
|
||||
res.status(400).send(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeAccountDelete(req, res, next) {
|
||||
try {
|
||||
const password = cleanText((req.body || {}).password, { trim: false });
|
||||
if (!password || password.length > LIMITS.password) {
|
||||
throw new Error(`password must be between 1 and ${LIMITS.password} characters`);
|
||||
}
|
||||
req.accountDelete = { password };
|
||||
next();
|
||||
}
|
||||
catch (err) {
|
||||
res.status(400).send(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizePersonInput(req, res, next) {
|
||||
try {
|
||||
const values = cleanPersonInput(req.body || {});
|
||||
@@ -120,4 +224,10 @@ function sanitizeLoginInput(req, res, next) {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { sanitizePersonInput, sanitizeLoginInput };
|
||||
module.exports = {
|
||||
sanitizePersonInput,
|
||||
sanitizeLoginInput,
|
||||
sanitizeProfileUpdate,
|
||||
sanitizeAccountDelete,
|
||||
validatePasswordRules,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user