Compare commits
2 Commits
763945fef4
...
25bbf5d20b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
25bbf5d20b | ||
|
|
1dee5232a0 |
14
backend/config/imageLimits.js
Normal file
14
backend/config/imageLimits.js
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
/**
|
||||||
|
* Image upload limits configuration
|
||||||
|
* Keep in sync with frontend/src/config/imageLimits.ts
|
||||||
|
*/
|
||||||
|
const IMAGE_LIMITS = {
|
||||||
|
items: 10,
|
||||||
|
forum: 10,
|
||||||
|
conditionChecks: 10,
|
||||||
|
damageReports: 10,
|
||||||
|
profile: 1,
|
||||||
|
messages: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = { IMAGE_LIMITS };
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
const multer = require('multer');
|
|
||||||
const path = require('path');
|
|
||||||
const { v4: uuidv4 } = require('uuid');
|
|
||||||
|
|
||||||
// Configure storage for profile images
|
|
||||||
const profileImageStorage = multer.diskStorage({
|
|
||||||
destination: function (req, file, cb) {
|
|
||||||
cb(null, path.join(__dirname, '../uploads/profiles'));
|
|
||||||
},
|
|
||||||
filename: function (req, file, cb) {
|
|
||||||
// Generate unique filename: uuid + original extension
|
|
||||||
const uniqueId = uuidv4();
|
|
||||||
const ext = path.extname(file.originalname);
|
|
||||||
cb(null, `${uniqueId}${ext}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// File filter to accept only images
|
|
||||||
const imageFileFilter = (req, file, cb) => {
|
|
||||||
// Accept images only
|
|
||||||
const allowedMimes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp'];
|
|
||||||
if (allowedMimes.includes(file.mimetype)) {
|
|
||||||
cb(null, true);
|
|
||||||
} else {
|
|
||||||
cb(new Error('Invalid file type. Only JPEG, PNG, GIF and WebP images are allowed.'), false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Create multer upload middleware for profile images
|
|
||||||
const uploadProfileImage = multer({
|
|
||||||
storage: profileImageStorage,
|
|
||||||
fileFilter: imageFileFilter,
|
|
||||||
limits: {
|
|
||||||
fileSize: 5 * 1024 * 1024 // 5MB limit
|
|
||||||
}
|
|
||||||
}).single('profileImage');
|
|
||||||
|
|
||||||
// Configure storage for message images
|
|
||||||
const messageImageStorage = multer.diskStorage({
|
|
||||||
destination: function (req, file, cb) {
|
|
||||||
cb(null, path.join(__dirname, '../uploads/messages'));
|
|
||||||
},
|
|
||||||
filename: function (req, file, cb) {
|
|
||||||
// Generate unique filename: uuid + original extension
|
|
||||||
const uniqueId = uuidv4();
|
|
||||||
const ext = path.extname(file.originalname);
|
|
||||||
cb(null, `${uniqueId}${ext}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create multer upload middleware for message images
|
|
||||||
const uploadMessageImage = multer({
|
|
||||||
storage: messageImageStorage,
|
|
||||||
fileFilter: imageFileFilter,
|
|
||||||
limits: {
|
|
||||||
fileSize: 5 * 1024 * 1024 // 5MB limit
|
|
||||||
}
|
|
||||||
}).single('image');
|
|
||||||
|
|
||||||
// Configure storage for forum images
|
|
||||||
const forumImageStorage = multer.diskStorage({
|
|
||||||
destination: function (req, file, cb) {
|
|
||||||
cb(null, path.join(__dirname, '../uploads/forum'));
|
|
||||||
},
|
|
||||||
filename: function (req, file, cb) {
|
|
||||||
const uniqueId = uuidv4();
|
|
||||||
const ext = path.extname(file.originalname);
|
|
||||||
cb(null, `${uniqueId}${ext}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Factory function to create forum image upload middleware
|
|
||||||
const createForumImageUpload = (maxFiles) => {
|
|
||||||
return multer({
|
|
||||||
storage: forumImageStorage,
|
|
||||||
fileFilter: imageFileFilter,
|
|
||||||
limits: {
|
|
||||||
fileSize: 5 * 1024 * 1024 // 5MB limit per file
|
|
||||||
}
|
|
||||||
}).array('images', maxFiles);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Create multer upload middleware for forum post images (up to 5 images)
|
|
||||||
const uploadForumPostImages = createForumImageUpload(5);
|
|
||||||
|
|
||||||
// Create multer upload middleware for forum comment images (up to 3 images)
|
|
||||||
const uploadForumCommentImages = createForumImageUpload(3);
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
uploadProfileImage,
|
|
||||||
uploadMessageImage,
|
|
||||||
uploadForumPostImages,
|
|
||||||
uploadForumCommentImages
|
|
||||||
};
|
|
||||||
130
backend/package-lock.json
generated
130
backend/package-lock.json
generated
@@ -29,7 +29,6 @@
|
|||||||
"jsdom": "^27.0.0",
|
"jsdom": "^27.0.0",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"morgan": "^1.10.1",
|
"morgan": "^1.10.1",
|
||||||
"multer": "^2.0.2",
|
|
||||||
"node-cron": "^3.0.3",
|
"node-cron": "^3.0.3",
|
||||||
"pg": "^8.16.3",
|
"pg": "^8.16.3",
|
||||||
"sequelize": "^6.37.7",
|
"sequelize": "^6.37.7",
|
||||||
@@ -5160,12 +5159,6 @@
|
|||||||
"node": ">= 8"
|
"node": ">= 8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/append-field": {
|
|
||||||
"version": "1.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
|
|
||||||
"integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/argparse": {
|
"node_modules/argparse": {
|
||||||
"version": "1.0.10",
|
"version": "1.0.10",
|
||||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
|
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
|
||||||
@@ -5539,19 +5532,9 @@
|
|||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
||||||
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
|
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/busboy": {
|
|
||||||
"version": "1.6.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
|
|
||||||
"integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
|
|
||||||
"dependencies": {
|
|
||||||
"streamsearch": "^1.1.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10.16.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/bytes": {
|
"node_modules/bytes": {
|
||||||
"version": "3.1.2",
|
"version": "3.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||||
@@ -5934,21 +5917,6 @@
|
|||||||
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
|
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/concat-stream": {
|
|
||||||
"version": "2.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
|
|
||||||
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
|
|
||||||
"engines": [
|
|
||||||
"node >= 6.0"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"buffer-from": "^1.0.0",
|
|
||||||
"inherits": "^2.0.3",
|
|
||||||
"readable-stream": "^3.0.2",
|
|
||||||
"typedarray": "^0.0.6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/config-chain": {
|
"node_modules/config-chain": {
|
||||||
"version": "1.1.13",
|
"version": "1.1.13",
|
||||||
"resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz",
|
"resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz",
|
||||||
@@ -8900,15 +8868,6 @@
|
|||||||
"url": "https://github.com/sponsors/isaacs"
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/minimist": {
|
|
||||||
"version": "1.2.8",
|
|
||||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
|
||||||
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/minipass": {
|
"node_modules/minipass": {
|
||||||
"version": "7.1.2",
|
"version": "7.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
|
||||||
@@ -8917,18 +8876,6 @@
|
|||||||
"node": ">=16 || 14 >=14.17"
|
"node": ">=16 || 14 >=14.17"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/mkdirp": {
|
|
||||||
"version": "0.5.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
|
|
||||||
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"minimist": "^1.2.6"
|
|
||||||
},
|
|
||||||
"bin": {
|
|
||||||
"mkdirp": "bin/cmd.js"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/moment": {
|
"node_modules/moment": {
|
||||||
"version": "2.30.1",
|
"version": "2.30.1",
|
||||||
"resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz",
|
"resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz",
|
||||||
@@ -8996,67 +8943,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
|
||||||
},
|
},
|
||||||
"node_modules/multer": {
|
|
||||||
"version": "2.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/multer/-/multer-2.0.2.tgz",
|
|
||||||
"integrity": "sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"append-field": "^1.0.0",
|
|
||||||
"busboy": "^1.6.0",
|
|
||||||
"concat-stream": "^2.0.0",
|
|
||||||
"mkdirp": "^0.5.6",
|
|
||||||
"object-assign": "^4.1.1",
|
|
||||||
"type-is": "^1.6.18",
|
|
||||||
"xtend": "^4.0.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 10.16.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/multer/node_modules/media-typer": {
|
|
||||||
"version": "0.3.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
|
|
||||||
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/multer/node_modules/mime-db": {
|
|
||||||
"version": "1.52.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
|
||||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/multer/node_modules/mime-types": {
|
|
||||||
"version": "2.1.35",
|
|
||||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
|
||||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"mime-db": "1.52.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/multer/node_modules/type-is": {
|
|
||||||
"version": "1.6.18",
|
|
||||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
|
|
||||||
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"media-typer": "0.3.0",
|
|
||||||
"mime-types": "~2.1.24"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/nanoid": {
|
"node_modules/nanoid": {
|
||||||
"version": "3.3.11",
|
"version": "3.3.11",
|
||||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
||||||
@@ -10637,14 +10523,6 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/streamsearch": {
|
|
||||||
"version": "1.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
|
|
||||||
"integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/strict-uri-encode": {
|
"node_modules/strict-uri-encode": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz",
|
||||||
@@ -11143,12 +11021,6 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/typedarray": {
|
|
||||||
"version": "0.0.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
|
|
||||||
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/uid-safe": {
|
"node_modules/uid-safe": {
|
||||||
"version": "2.1.5",
|
"version": "2.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz",
|
||||||
|
|||||||
@@ -54,7 +54,6 @@
|
|||||||
"jsdom": "^27.0.0",
|
"jsdom": "^27.0.0",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"morgan": "^1.10.1",
|
"morgan": "^1.10.1",
|
||||||
"multer": "^2.0.2",
|
|
||||||
"node-cron": "^3.0.3",
|
"node-cron": "^3.0.3",
|
||||||
"pg": "^8.16.3",
|
"pg": "^8.16.3",
|
||||||
"sequelize": "^6.37.7",
|
"sequelize": "^6.37.7",
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ const express = require("express");
|
|||||||
const { authenticateToken } = require("../middleware/auth");
|
const { authenticateToken } = require("../middleware/auth");
|
||||||
const ConditionCheckService = require("../services/conditionCheckService");
|
const ConditionCheckService = require("../services/conditionCheckService");
|
||||||
const logger = require("../utils/logger");
|
const logger = require("../utils/logger");
|
||||||
|
const { validateS3Keys } = require("../utils/s3KeyValidator");
|
||||||
|
const { IMAGE_LIMITS } = require("../config/imageLimits");
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
@@ -13,10 +15,24 @@ router.post("/:rentalId", authenticateToken, async (req, res) => {
|
|||||||
const userId = req.user.id;
|
const userId = req.user.id;
|
||||||
|
|
||||||
// Ensure imageFilenames is an array (S3 keys)
|
// Ensure imageFilenames is an array (S3 keys)
|
||||||
const imageFilenames = Array.isArray(rawImageFilenames)
|
const imageFilenamesArray = Array.isArray(rawImageFilenames)
|
||||||
? rawImageFilenames
|
? rawImageFilenames
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
|
// Validate S3 keys format and folder
|
||||||
|
const keyValidation = validateS3Keys(imageFilenamesArray, "condition-checks", {
|
||||||
|
maxKeys: IMAGE_LIMITS.conditionChecks,
|
||||||
|
});
|
||||||
|
if (!keyValidation.valid) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: keyValidation.error,
|
||||||
|
details: keyValidation.invalidKeys,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const imageFilenames = imageFilenamesArray;
|
||||||
|
|
||||||
const conditionCheck = await ConditionCheckService.submitConditionCheck(
|
const conditionCheck = await ConditionCheckService.submitConditionCheck(
|
||||||
rentalId,
|
rentalId,
|
||||||
checkType,
|
checkType,
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ const logger = require('../utils/logger');
|
|||||||
const emailServices = require('../services/email');
|
const emailServices = require('../services/email');
|
||||||
const googleMapsService = require('../services/googleMapsService');
|
const googleMapsService = require('../services/googleMapsService');
|
||||||
const locationService = require('../services/locationService');
|
const locationService = require('../services/locationService');
|
||||||
|
const { validateS3Keys } = require('../utils/s3KeyValidator');
|
||||||
|
const { IMAGE_LIMITS } = require('../config/imageLimits');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
// Helper function to build nested comment tree
|
// Helper function to build nested comment tree
|
||||||
@@ -239,10 +241,20 @@ router.get('/posts/:id', optionalAuth, async (req, res, next) => {
|
|||||||
// POST /api/forum/posts - Create new post
|
// POST /api/forum/posts - Create new post
|
||||||
router.post('/posts', authenticateToken, async (req, res, next) => {
|
router.post('/posts', authenticateToken, async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
let { title, content, category, tags, zipCode, latitude: providedLat, longitude: providedLng, imageFilenames } = req.body;
|
let { title, content, category, tags, zipCode, latitude: providedLat, longitude: providedLng, imageFilenames: rawImageFilenames } = req.body;
|
||||||
|
|
||||||
// Ensure imageFilenames is an array
|
// Ensure imageFilenames is an array and validate S3 keys
|
||||||
imageFilenames = Array.isArray(imageFilenames) ? imageFilenames : [];
|
const imageFilenamesArray = Array.isArray(rawImageFilenames) ? rawImageFilenames : [];
|
||||||
|
|
||||||
|
const keyValidation = validateS3Keys(imageFilenamesArray, 'forum', { maxKeys: IMAGE_LIMITS.forum });
|
||||||
|
if (!keyValidation.valid) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: keyValidation.error,
|
||||||
|
details: keyValidation.invalidKeys
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const imageFilenames = imageFilenamesArray;
|
||||||
|
|
||||||
// Initialize location fields
|
// Initialize location fields
|
||||||
let latitude = null;
|
let latitude = null;
|
||||||
@@ -488,9 +500,26 @@ router.put('/posts/:id', authenticateToken, async (req, res, next) => {
|
|||||||
return res.status(403).json({ error: 'Unauthorized' });
|
return res.status(403).json({ error: 'Unauthorized' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { title, content, category, tags } = req.body;
|
const { title, content, category, tags, imageFilenames: rawImageFilenames } = req.body;
|
||||||
|
|
||||||
await post.update({ title, content, category });
|
// Build update object
|
||||||
|
const updateData = { title, content, category };
|
||||||
|
|
||||||
|
// Handle imageFilenames if provided
|
||||||
|
if (rawImageFilenames !== undefined) {
|
||||||
|
const imageFilenamesArray = Array.isArray(rawImageFilenames) ? rawImageFilenames : [];
|
||||||
|
|
||||||
|
const keyValidation = validateS3Keys(imageFilenamesArray, 'forum', { maxKeys: IMAGE_LIMITS.forum });
|
||||||
|
if (!keyValidation.valid) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: keyValidation.error,
|
||||||
|
details: keyValidation.invalidKeys
|
||||||
|
});
|
||||||
|
}
|
||||||
|
updateData.imageFilenames = imageFilenamesArray;
|
||||||
|
}
|
||||||
|
|
||||||
|
await post.update(updateData);
|
||||||
|
|
||||||
// Update tags if provided
|
// Update tags if provided
|
||||||
if (tags !== undefined) {
|
if (tags !== undefined) {
|
||||||
@@ -927,8 +956,18 @@ router.post('/posts/:id/comments', authenticateToken, async (req, res, next) =>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure imageFilenames is an array
|
// Ensure imageFilenames is an array and validate S3 keys
|
||||||
const imageFilenames = Array.isArray(rawImageFilenames) ? rawImageFilenames : [];
|
const imageFilenamesArray = Array.isArray(rawImageFilenames) ? rawImageFilenames : [];
|
||||||
|
|
||||||
|
const keyValidation = validateS3Keys(imageFilenamesArray, 'forum', { maxKeys: IMAGE_LIMITS.forum });
|
||||||
|
if (!keyValidation.valid) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: keyValidation.error,
|
||||||
|
details: keyValidation.invalidKeys
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const imageFilenames = imageFilenamesArray;
|
||||||
|
|
||||||
const comment = await ForumComment.create({
|
const comment = await ForumComment.create({
|
||||||
postId: req.params.id,
|
postId: req.params.id,
|
||||||
|
|||||||
@@ -3,8 +3,56 @@ const { Op } = require("sequelize");
|
|||||||
const { Item, User, Rental } = require("../models"); // Import from models/index.js to get models with associations
|
const { Item, User, Rental } = require("../models"); // Import from models/index.js to get models with associations
|
||||||
const { authenticateToken, requireVerifiedEmail, requireAdmin, optionalAuth } = require("../middleware/auth");
|
const { authenticateToken, requireVerifiedEmail, requireAdmin, optionalAuth } = require("../middleware/auth");
|
||||||
const logger = require("../utils/logger");
|
const logger = require("../utils/logger");
|
||||||
|
const { validateS3Keys } = require("../utils/s3KeyValidator");
|
||||||
|
const { IMAGE_LIMITS } = require("../config/imageLimits");
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
|
// Allowed fields for item create/update (prevents mass assignment)
|
||||||
|
const ALLOWED_ITEM_FIELDS = [
|
||||||
|
'name',
|
||||||
|
'description',
|
||||||
|
'pickUpAvailable',
|
||||||
|
'localDeliveryAvailable',
|
||||||
|
'localDeliveryRadius',
|
||||||
|
'shippingAvailable',
|
||||||
|
'inPlaceUseAvailable',
|
||||||
|
'pricePerHour',
|
||||||
|
'pricePerDay',
|
||||||
|
'pricePerWeek',
|
||||||
|
'pricePerMonth',
|
||||||
|
'replacementCost',
|
||||||
|
'address1',
|
||||||
|
'address2',
|
||||||
|
'city',
|
||||||
|
'state',
|
||||||
|
'zipCode',
|
||||||
|
'country',
|
||||||
|
'latitude',
|
||||||
|
'longitude',
|
||||||
|
'imageFilenames',
|
||||||
|
'isAvailable',
|
||||||
|
'rules',
|
||||||
|
'availableAfter',
|
||||||
|
'availableBefore',
|
||||||
|
'specifyTimesPerDay',
|
||||||
|
'weeklyTimes',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract only allowed fields from request body
|
||||||
|
* @param {Object} body - Request body
|
||||||
|
* @returns {Object} - Object with only allowed fields
|
||||||
|
*/
|
||||||
|
function extractAllowedFields(body) {
|
||||||
|
const result = {};
|
||||||
|
for (const field of ALLOWED_ITEM_FIELDS) {
|
||||||
|
if (body[field] !== undefined) {
|
||||||
|
result[field] = body[field];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
router.get("/", async (req, res, next) => {
|
router.get("/", async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const {
|
const {
|
||||||
@@ -232,8 +280,27 @@ router.get("/:id", optionalAuth, async (req, res, next) => {
|
|||||||
|
|
||||||
router.post("/", authenticateToken, requireVerifiedEmail, async (req, res, next) => {
|
router.post("/", authenticateToken, requireVerifiedEmail, async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
|
// Extract only allowed fields (prevents mass assignment)
|
||||||
|
const allowedData = extractAllowedFields(req.body);
|
||||||
|
|
||||||
|
// Validate imageFilenames if provided
|
||||||
|
if (allowedData.imageFilenames) {
|
||||||
|
const imageFilenames = Array.isArray(allowedData.imageFilenames)
|
||||||
|
? allowedData.imageFilenames
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const keyValidation = validateS3Keys(imageFilenames, 'items', { maxKeys: IMAGE_LIMITS.items });
|
||||||
|
if (!keyValidation.valid) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: keyValidation.error,
|
||||||
|
details: keyValidation.invalidKeys
|
||||||
|
});
|
||||||
|
}
|
||||||
|
allowedData.imageFilenames = imageFilenames;
|
||||||
|
}
|
||||||
|
|
||||||
const item = await Item.create({
|
const item = await Item.create({
|
||||||
...req.body,
|
...allowedData,
|
||||||
ownerId: req.user.id,
|
ownerId: req.user.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -300,7 +367,26 @@ router.put("/:id", authenticateToken, async (req, res, next) => {
|
|||||||
return res.status(403).json({ error: "Unauthorized" });
|
return res.status(403).json({ error: "Unauthorized" });
|
||||||
}
|
}
|
||||||
|
|
||||||
await item.update(req.body);
|
// Extract only allowed fields (prevents mass assignment)
|
||||||
|
const allowedData = extractAllowedFields(req.body);
|
||||||
|
|
||||||
|
// Validate imageFilenames if provided
|
||||||
|
if (allowedData.imageFilenames !== undefined) {
|
||||||
|
const imageFilenames = Array.isArray(allowedData.imageFilenames)
|
||||||
|
? allowedData.imageFilenames
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const keyValidation = validateS3Keys(imageFilenames, 'items', { maxKeys: IMAGE_LIMITS.items });
|
||||||
|
if (!keyValidation.valid) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: keyValidation.error,
|
||||||
|
details: keyValidation.invalidKeys
|
||||||
|
});
|
||||||
|
}
|
||||||
|
allowedData.imageFilenames = imageFilenames;
|
||||||
|
}
|
||||||
|
|
||||||
|
await item.update(allowedData);
|
||||||
|
|
||||||
const updatedItem = await Item.findByPk(item.id, {
|
const updatedItem = await Item.findByPk(item.id, {
|
||||||
include: [
|
include: [
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ const { Op } = require('sequelize');
|
|||||||
const emailServices = require('../services/email');
|
const emailServices = require('../services/email');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
const { validateS3Keys } = require('../utils/s3KeyValidator');
|
||||||
|
const { IMAGE_LIMITS } = require('../config/imageLimits');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
// Get all messages for the current user (inbox)
|
// Get all messages for the current user (inbox)
|
||||||
@@ -240,6 +242,17 @@ router.post('/', authenticateToken, async (req, res, next) => {
|
|||||||
try {
|
try {
|
||||||
const { receiverId, content, imageFilename } = req.body;
|
const { receiverId, content, imageFilename } = req.body;
|
||||||
|
|
||||||
|
// Validate imageFilename if provided
|
||||||
|
if (imageFilename) {
|
||||||
|
const keyValidation = validateS3Keys([imageFilename], 'messages', { maxKeys: IMAGE_LIMITS.messages });
|
||||||
|
if (!keyValidation.valid) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: keyValidation.error,
|
||||||
|
details: keyValidation.invalidKeys
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Check if receiver exists
|
// Check if receiver exists
|
||||||
const receiver = await User.findByPk(receiverId);
|
const receiver = await User.findByPk(receiverId);
|
||||||
if (!receiver) {
|
if (!receiver) {
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ const LateReturnService = require("../services/lateReturnService");
|
|||||||
const DamageAssessmentService = require("../services/damageAssessmentService");
|
const DamageAssessmentService = require("../services/damageAssessmentService");
|
||||||
const emailServices = require("../services/email");
|
const emailServices = require("../services/email");
|
||||||
const logger = require("../utils/logger");
|
const logger = require("../utils/logger");
|
||||||
|
const { validateS3Keys } = require("../utils/s3KeyValidator");
|
||||||
|
const { IMAGE_LIMITS } = require("../config/imageLimits");
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
// Helper function to check and update review visibility
|
// Helper function to check and update review visibility
|
||||||
@@ -1257,12 +1259,53 @@ router.post("/:id/mark-return", authenticateToken, async (req, res, next) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Allowed fields for damage report (prevents mass assignment)
|
||||||
|
const ALLOWED_DAMAGE_REPORT_FIELDS = [
|
||||||
|
'description',
|
||||||
|
'canBeFixed',
|
||||||
|
'repairCost',
|
||||||
|
'needsReplacement',
|
||||||
|
'replacementCost',
|
||||||
|
'proofOfOwnership',
|
||||||
|
'actualReturnDateTime',
|
||||||
|
'imageFilenames',
|
||||||
|
];
|
||||||
|
|
||||||
|
function extractAllowedDamageFields(body) {
|
||||||
|
const result = {};
|
||||||
|
for (const field of ALLOWED_DAMAGE_REPORT_FIELDS) {
|
||||||
|
if (body[field] !== undefined) {
|
||||||
|
result[field] = body[field];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
// Report item as damaged (owner only)
|
// Report item as damaged (owner only)
|
||||||
router.post("/:id/report-damage", authenticateToken, async (req, res, next) => {
|
router.post("/:id/report-damage", authenticateToken, async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const rentalId = req.params.id;
|
const rentalId = req.params.id;
|
||||||
const userId = req.user.id;
|
const userId = req.user.id;
|
||||||
const damageInfo = req.body;
|
// Extract only allowed fields (prevents mass assignment)
|
||||||
|
const damageInfo = extractAllowedDamageFields(req.body);
|
||||||
|
|
||||||
|
// Validate imageFilenames if provided
|
||||||
|
if (damageInfo.imageFilenames !== undefined) {
|
||||||
|
const imageFilenamesArray = Array.isArray(damageInfo.imageFilenames)
|
||||||
|
? damageInfo.imageFilenames
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const keyValidation = validateS3Keys(imageFilenamesArray, 'damage-reports', {
|
||||||
|
maxKeys: IMAGE_LIMITS.damageReports,
|
||||||
|
});
|
||||||
|
if (!keyValidation.valid) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: keyValidation.error,
|
||||||
|
details: keyValidation.invalidKeys,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
damageInfo.imageFilenames = imageFilenamesArray;
|
||||||
|
}
|
||||||
|
|
||||||
const result = await DamageAssessmentService.processDamageAssessment(
|
const result = await DamageAssessmentService.processDamageAssessment(
|
||||||
rentalId,
|
rentalId,
|
||||||
|
|||||||
@@ -1,13 +1,66 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { User, UserAddress } = require('../models'); // Import from models/index.js to get models with associations
|
const { User, UserAddress } = require('../models'); // Import from models/index.js to get models with associations
|
||||||
const { authenticateToken } = require('../middleware/auth');
|
const { authenticateToken } = require('../middleware/auth');
|
||||||
const { uploadProfileImage } = require('../middleware/upload');
|
|
||||||
const logger = require('../utils/logger');
|
const logger = require('../utils/logger');
|
||||||
const userService = require('../services/UserService');
|
const userService = require('../services/UserService');
|
||||||
const fs = require('fs').promises;
|
const { validateS3Keys } = require('../utils/s3KeyValidator');
|
||||||
const path = require('path');
|
const { IMAGE_LIMITS } = require('../config/imageLimits');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
|
// Allowed fields for profile update (prevents mass assignment)
|
||||||
|
const ALLOWED_PROFILE_FIELDS = [
|
||||||
|
'firstName',
|
||||||
|
'lastName',
|
||||||
|
'email',
|
||||||
|
'phone',
|
||||||
|
'address1',
|
||||||
|
'address2',
|
||||||
|
'city',
|
||||||
|
'state',
|
||||||
|
'zipCode',
|
||||||
|
'country',
|
||||||
|
'imageFilename',
|
||||||
|
'itemRequestNotificationRadius',
|
||||||
|
];
|
||||||
|
|
||||||
|
// Allowed fields for user address create/update (prevents mass assignment)
|
||||||
|
const ALLOWED_ADDRESS_FIELDS = [
|
||||||
|
'address1',
|
||||||
|
'address2',
|
||||||
|
'city',
|
||||||
|
'state',
|
||||||
|
'zipCode',
|
||||||
|
'country',
|
||||||
|
'latitude',
|
||||||
|
'longitude',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract only allowed fields from request body
|
||||||
|
*/
|
||||||
|
function extractAllowedProfileFields(body) {
|
||||||
|
const result = {};
|
||||||
|
for (const field of ALLOWED_PROFILE_FIELDS) {
|
||||||
|
if (body[field] !== undefined) {
|
||||||
|
result[field] = body[field];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract only allowed address fields from request body
|
||||||
|
*/
|
||||||
|
function extractAllowedAddressFields(body) {
|
||||||
|
const result = {};
|
||||||
|
for (const field of ALLOWED_ADDRESS_FIELDS) {
|
||||||
|
if (body[field] !== undefined) {
|
||||||
|
result[field] = body[field];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
router.get('/profile', authenticateToken, async (req, res, next) => {
|
router.get('/profile', authenticateToken, async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const user = await User.findByPk(req.user.id, {
|
const user = await User.findByPk(req.user.id, {
|
||||||
@@ -58,7 +111,9 @@ router.get('/addresses', authenticateToken, async (req, res, next) => {
|
|||||||
|
|
||||||
router.post('/addresses', authenticateToken, async (req, res, next) => {
|
router.post('/addresses', authenticateToken, async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const address = await userService.createUserAddress(req.user.id, req.body);
|
// Extract only allowed fields (prevents mass assignment)
|
||||||
|
const allowedData = extractAllowedAddressFields(req.body);
|
||||||
|
const address = await userService.createUserAddress(req.user.id, allowedData);
|
||||||
|
|
||||||
res.status(201).json(address);
|
res.status(201).json(address);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -75,7 +130,9 @@ router.post('/addresses', authenticateToken, async (req, res, next) => {
|
|||||||
|
|
||||||
router.put('/addresses/:id', authenticateToken, async (req, res, next) => {
|
router.put('/addresses/:id', authenticateToken, async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const address = await userService.updateUserAddress(req.user.id, req.params.id, req.body);
|
// Extract only allowed fields (prevents mass assignment)
|
||||||
|
const allowedData = extractAllowedAddressFields(req.body);
|
||||||
|
const address = await userService.updateUserAddress(req.user.id, req.params.id, allowedData);
|
||||||
|
|
||||||
res.json(address);
|
res.json(address);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -182,8 +239,22 @@ router.get('/:id', async (req, res, next) => {
|
|||||||
|
|
||||||
router.put('/profile', authenticateToken, async (req, res, next) => {
|
router.put('/profile', authenticateToken, async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
|
// Extract only allowed fields (prevents mass assignment)
|
||||||
|
const allowedData = extractAllowedProfileFields(req.body);
|
||||||
|
|
||||||
|
// Validate imageFilename if provided
|
||||||
|
if (allowedData.imageFilename !== undefined && allowedData.imageFilename !== null) {
|
||||||
|
const keyValidation = validateS3Keys([allowedData.imageFilename], 'profiles', { maxKeys: IMAGE_LIMITS.profile });
|
||||||
|
if (!keyValidation.valid) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: keyValidation.error,
|
||||||
|
details: keyValidation.invalidKeys
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Use UserService to handle update and email notification
|
// Use UserService to handle update and email notification
|
||||||
const updatedUser = await userService.updateProfile(req.user.id, req.body);
|
const updatedUser = await userService.updateProfile(req.user.id, allowedData);
|
||||||
|
|
||||||
res.json(updatedUser);
|
res.json(updatedUser);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -192,65 +263,4 @@ router.put('/profile', authenticateToken, async (req, res, next) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Upload profile image endpoint
|
|
||||||
router.post('/profile/image', authenticateToken, (req, res) => {
|
|
||||||
uploadProfileImage(req, res, async (err) => {
|
|
||||||
if (err) {
|
|
||||||
const reqLogger = logger.withRequestId(req.id);
|
|
||||||
reqLogger.error("Profile image upload error", {
|
|
||||||
error: err.message,
|
|
||||||
userId: req.user.id
|
|
||||||
});
|
|
||||||
return res.status(400).json({ error: err.message });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!req.file) {
|
|
||||||
return res.status(400).json({ error: 'No file uploaded' });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Delete old profile image if exists
|
|
||||||
const user = await User.findByPk(req.user.id);
|
|
||||||
if (user.imageFilename) {
|
|
||||||
const oldImagePath = path.join(__dirname, '../uploads/profiles', user.imageFilename);
|
|
||||||
try {
|
|
||||||
await fs.unlink(oldImagePath);
|
|
||||||
} catch (unlinkErr) {
|
|
||||||
const reqLogger = logger.withRequestId(req.id);
|
|
||||||
reqLogger.warn("Error deleting old profile image", {
|
|
||||||
error: unlinkErr.message,
|
|
||||||
userId: req.user.id,
|
|
||||||
oldImagePath
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update user with new image filename
|
|
||||||
await user.update({
|
|
||||||
imageFilename: req.file.filename
|
|
||||||
});
|
|
||||||
|
|
||||||
const reqLogger = logger.withRequestId(req.id);
|
|
||||||
reqLogger.info("Profile image uploaded successfully", {
|
|
||||||
userId: req.user.id,
|
|
||||||
filename: req.file.filename
|
|
||||||
});
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
message: 'Profile image uploaded successfully',
|
|
||||||
filename: req.file.filename,
|
|
||||||
imageUrl: `/uploads/profiles/${req.file.filename}`
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
const reqLogger = logger.withRequestId(req.id);
|
|
||||||
reqLogger.error("Profile image database update failed", {
|
|
||||||
error: error.message,
|
|
||||||
stack: error.stack,
|
|
||||||
userId: req.user.id
|
|
||||||
});
|
|
||||||
res.status(500).json({ error: 'Failed to update profile image' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
@@ -19,7 +19,7 @@ class DamageAssessmentService {
|
|||||||
replacementCost,
|
replacementCost,
|
||||||
proofOfOwnership,
|
proofOfOwnership,
|
||||||
actualReturnDateTime,
|
actualReturnDateTime,
|
||||||
photos = [],
|
imageFilenames = [],
|
||||||
} = damageInfo;
|
} = damageInfo;
|
||||||
|
|
||||||
const rental = await Rental.findByPk(rentalId, {
|
const rental = await Rental.findByPk(rentalId, {
|
||||||
@@ -98,7 +98,7 @@ class DamageAssessmentService {
|
|||||||
needsReplacement,
|
needsReplacement,
|
||||||
replacementCost: needsReplacement ? parseFloat(replacementCost) : null,
|
replacementCost: needsReplacement ? parseFloat(replacementCost) : null,
|
||||||
proofOfOwnership: proofOfOwnership || [],
|
proofOfOwnership: proofOfOwnership || [],
|
||||||
photos,
|
imageFilenames,
|
||||||
assessedAt: new Date(),
|
assessedAt: new Date(),
|
||||||
assessedBy: userId,
|
assessedBy: userId,
|
||||||
feeCalculation,
|
feeCalculation,
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
const request = require('supertest');
|
const request = require("supertest");
|
||||||
const express = require('express');
|
const express = require("express");
|
||||||
const usersRouter = require('../../../routes/users');
|
const usersRouter = require("../../../routes/users");
|
||||||
|
|
||||||
// Mock all dependencies
|
// Mock all dependencies
|
||||||
jest.mock('../../../models', () => ({
|
jest.mock("../../../models", () => ({
|
||||||
User: {
|
User: {
|
||||||
findByPk: jest.fn(),
|
findByPk: jest.fn(),
|
||||||
update: jest.fn(),
|
update: jest.fn(),
|
||||||
@@ -15,41 +15,22 @@ jest.mock('../../../models', () => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
jest.mock('../../../middleware/auth', () => ({
|
jest.mock("../../../middleware/auth", () => ({
|
||||||
authenticateToken: jest.fn((req, res, next) => {
|
authenticateToken: jest.fn((req, res, next) => {
|
||||||
req.user = {
|
req.user = {
|
||||||
id: 1,
|
id: 1,
|
||||||
update: jest.fn()
|
update: jest.fn(),
|
||||||
};
|
};
|
||||||
next();
|
next();
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
jest.mock('../../../middleware/upload', () => ({
|
const { User, UserAddress } = require("../../../models");
|
||||||
uploadProfileImage: jest.fn((req, res, callback) => {
|
|
||||||
// Mock successful upload
|
|
||||||
req.file = {
|
|
||||||
filename: 'test-profile.jpg'
|
|
||||||
};
|
|
||||||
callback(null);
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
jest.mock('fs', () => ({
|
|
||||||
promises: {
|
|
||||||
unlink: jest.fn(),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
jest.mock('path');
|
|
||||||
const { User, UserAddress } = require('../../../models');
|
|
||||||
const { uploadProfileImage } = require('../../../middleware/upload');
|
|
||||||
const fs = require('fs').promises;
|
|
||||||
|
|
||||||
// Create express app with the router
|
// Create express app with the router
|
||||||
const app = express();
|
const app = express();
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
app.use('/users', usersRouter);
|
app.use("/users", usersRouter);
|
||||||
|
|
||||||
// Mock models
|
// Mock models
|
||||||
const mockUserFindByPk = User.findByPk;
|
const mockUserFindByPk = User.findByPk;
|
||||||
@@ -58,97 +39,96 @@ const mockUserAddressFindAll = UserAddress.findAll;
|
|||||||
const mockUserAddressFindByPk = UserAddress.findByPk;
|
const mockUserAddressFindByPk = UserAddress.findByPk;
|
||||||
const mockUserAddressCreate = UserAddress.create;
|
const mockUserAddressCreate = UserAddress.create;
|
||||||
|
|
||||||
describe('Users Routes', () => {
|
describe("Users Routes", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('GET /profile', () => {
|
describe("GET /profile", () => {
|
||||||
it('should get user profile for authenticated user', async () => {
|
it("should get user profile for authenticated user", async () => {
|
||||||
const mockUser = {
|
const mockUser = {
|
||||||
id: 1,
|
id: 1,
|
||||||
firstName: 'John',
|
firstName: "John",
|
||||||
lastName: 'Doe',
|
lastName: "Doe",
|
||||||
email: 'john@example.com',
|
email: "john@example.com",
|
||||||
phone: '555-1234',
|
phone: "555-1234",
|
||||||
imageFilename: 'profile.jpg',
|
imageFilename: "profile.jpg",
|
||||||
};
|
};
|
||||||
|
|
||||||
mockUserFindByPk.mockResolvedValue(mockUser);
|
mockUserFindByPk.mockResolvedValue(mockUser);
|
||||||
|
|
||||||
const response = await request(app)
|
const response = await request(app).get("/users/profile");
|
||||||
.get('/users/profile');
|
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
expect(response.body).toEqual(mockUser);
|
expect(response.body).toEqual(mockUser);
|
||||||
expect(mockUserFindByPk).toHaveBeenCalledWith(1, {
|
expect(mockUserFindByPk).toHaveBeenCalledWith(1, {
|
||||||
attributes: { exclude: ['password'] }
|
attributes: { exclude: ["password"] },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle database errors', async () => {
|
it("should handle database errors", async () => {
|
||||||
mockUserFindByPk.mockRejectedValue(new Error('Database error'));
|
mockUserFindByPk.mockRejectedValue(new Error("Database error"));
|
||||||
|
|
||||||
const response = await request(app)
|
const response = await request(app).get("/users/profile");
|
||||||
.get('/users/profile');
|
|
||||||
|
|
||||||
expect(response.status).toBe(500);
|
expect(response.status).toBe(500);
|
||||||
expect(response.body).toEqual({ error: 'Database error' });
|
expect(response.body).toEqual({ error: "Database error" });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('GET /addresses', () => {
|
describe("GET /addresses", () => {
|
||||||
it('should get user addresses', async () => {
|
it("should get user addresses", async () => {
|
||||||
const mockAddresses = [
|
const mockAddresses = [
|
||||||
{
|
{
|
||||||
id: 1,
|
id: 1,
|
||||||
userId: 1,
|
userId: 1,
|
||||||
address1: '123 Main St',
|
address1: "123 Main St",
|
||||||
city: 'New York',
|
city: "New York",
|
||||||
isPrimary: true,
|
isPrimary: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 2,
|
id: 2,
|
||||||
userId: 1,
|
userId: 1,
|
||||||
address1: '456 Oak Ave',
|
address1: "456 Oak Ave",
|
||||||
city: 'Boston',
|
city: "Boston",
|
||||||
isPrimary: false,
|
isPrimary: false,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
mockUserAddressFindAll.mockResolvedValue(mockAddresses);
|
mockUserAddressFindAll.mockResolvedValue(mockAddresses);
|
||||||
|
|
||||||
const response = await request(app)
|
const response = await request(app).get("/users/addresses");
|
||||||
.get('/users/addresses');
|
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
expect(response.body).toEqual(mockAddresses);
|
expect(response.body).toEqual(mockAddresses);
|
||||||
expect(mockUserAddressFindAll).toHaveBeenCalledWith({
|
expect(mockUserAddressFindAll).toHaveBeenCalledWith({
|
||||||
where: { userId: 1 },
|
where: { userId: 1 },
|
||||||
order: [['isPrimary', 'DESC'], ['createdAt', 'ASC']]
|
order: [
|
||||||
|
["isPrimary", "DESC"],
|
||||||
|
["createdAt", "ASC"],
|
||||||
|
],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle database errors', async () => {
|
it("should handle database errors", async () => {
|
||||||
mockUserAddressFindAll.mockRejectedValue(new Error('Database error'));
|
mockUserAddressFindAll.mockRejectedValue(new Error("Database error"));
|
||||||
|
|
||||||
const response = await request(app)
|
const response = await request(app).get("/users/addresses");
|
||||||
.get('/users/addresses');
|
|
||||||
|
|
||||||
expect(response.status).toBe(500);
|
expect(response.status).toBe(500);
|
||||||
expect(response.body).toEqual({ error: 'Database error' });
|
expect(response.body).toEqual({ error: "Database error" });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('POST /addresses', () => {
|
describe("POST /addresses", () => {
|
||||||
it('should create a new address', async () => {
|
it("should create a new address", async () => {
|
||||||
const addressData = {
|
const addressData = {
|
||||||
address1: '789 Pine St',
|
address1: "789 Pine St",
|
||||||
address2: 'Apt 4B',
|
address2: "Apt 4B",
|
||||||
city: 'Chicago',
|
city: "Chicago",
|
||||||
state: 'IL',
|
state: "IL",
|
||||||
zipCode: '60601',
|
zipCode: "60601",
|
||||||
country: 'USA',
|
country: "USA",
|
||||||
isPrimary: false,
|
isPrimary: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -161,38 +141,36 @@ describe('Users Routes', () => {
|
|||||||
mockUserAddressCreate.mockResolvedValue(mockCreatedAddress);
|
mockUserAddressCreate.mockResolvedValue(mockCreatedAddress);
|
||||||
|
|
||||||
const response = await request(app)
|
const response = await request(app)
|
||||||
.post('/users/addresses')
|
.post("/users/addresses")
|
||||||
.send(addressData);
|
.send(addressData);
|
||||||
|
|
||||||
expect(response.status).toBe(201);
|
expect(response.status).toBe(201);
|
||||||
expect(response.body).toEqual(mockCreatedAddress);
|
expect(response.body).toEqual(mockCreatedAddress);
|
||||||
expect(mockUserAddressCreate).toHaveBeenCalledWith({
|
expect(mockUserAddressCreate).toHaveBeenCalledWith({
|
||||||
...addressData,
|
...addressData,
|
||||||
userId: 1
|
userId: 1,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle database errors during creation', async () => {
|
it("should handle database errors during creation", async () => {
|
||||||
mockUserAddressCreate.mockRejectedValue(new Error('Database error'));
|
mockUserAddressCreate.mockRejectedValue(new Error("Database error"));
|
||||||
|
|
||||||
const response = await request(app)
|
const response = await request(app).post("/users/addresses").send({
|
||||||
.post('/users/addresses')
|
address1: "789 Pine St",
|
||||||
.send({
|
city: "Chicago",
|
||||||
address1: '789 Pine St',
|
});
|
||||||
city: 'Chicago',
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(response.status).toBe(500);
|
expect(response.status).toBe(500);
|
||||||
expect(response.body).toEqual({ error: 'Database error' });
|
expect(response.body).toEqual({ error: "Database error" });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('PUT /addresses/:id', () => {
|
describe("PUT /addresses/:id", () => {
|
||||||
const mockAddress = {
|
const mockAddress = {
|
||||||
id: 1,
|
id: 1,
|
||||||
userId: 1,
|
userId: 1,
|
||||||
address1: '123 Main St',
|
address1: "123 Main St",
|
||||||
city: 'New York',
|
city: "New York",
|
||||||
update: jest.fn(),
|
update: jest.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -200,68 +178,68 @@ describe('Users Routes', () => {
|
|||||||
mockUserAddressFindByPk.mockResolvedValue(mockAddress);
|
mockUserAddressFindByPk.mockResolvedValue(mockAddress);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should update user address', async () => {
|
it("should update user address", async () => {
|
||||||
const updateData = {
|
const updateData = {
|
||||||
address1: '123 Updated St',
|
address1: "123 Updated St",
|
||||||
city: 'Updated City',
|
city: "Updated City",
|
||||||
};
|
};
|
||||||
|
|
||||||
mockAddress.update.mockResolvedValue();
|
mockAddress.update.mockResolvedValue();
|
||||||
|
|
||||||
const response = await request(app)
|
const response = await request(app)
|
||||||
.put('/users/addresses/1')
|
.put("/users/addresses/1")
|
||||||
.send(updateData);
|
.send(updateData);
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
expect(response.body).toEqual({
|
expect(response.body).toEqual({
|
||||||
id: 1,
|
id: 1,
|
||||||
userId: 1,
|
userId: 1,
|
||||||
address1: '123 Main St',
|
address1: "123 Main St",
|
||||||
city: 'New York',
|
city: "New York",
|
||||||
});
|
});
|
||||||
expect(mockAddress.update).toHaveBeenCalledWith(updateData);
|
expect(mockAddress.update).toHaveBeenCalledWith(updateData);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return 404 for non-existent address', async () => {
|
it("should return 404 for non-existent address", async () => {
|
||||||
mockUserAddressFindByPk.mockResolvedValue(null);
|
mockUserAddressFindByPk.mockResolvedValue(null);
|
||||||
|
|
||||||
const response = await request(app)
|
const response = await request(app)
|
||||||
.put('/users/addresses/999')
|
.put("/users/addresses/999")
|
||||||
.send({ address1: 'Updated St' });
|
.send({ address1: "Updated St" });
|
||||||
|
|
||||||
expect(response.status).toBe(404);
|
expect(response.status).toBe(404);
|
||||||
expect(response.body).toEqual({ error: 'Address not found' });
|
expect(response.body).toEqual({ error: "Address not found" });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return 403 for unauthorized user', async () => {
|
it("should return 403 for unauthorized user", async () => {
|
||||||
const unauthorizedAddress = { ...mockAddress, userId: 2 };
|
const unauthorizedAddress = { ...mockAddress, userId: 2 };
|
||||||
mockUserAddressFindByPk.mockResolvedValue(unauthorizedAddress);
|
mockUserAddressFindByPk.mockResolvedValue(unauthorizedAddress);
|
||||||
|
|
||||||
const response = await request(app)
|
const response = await request(app)
|
||||||
.put('/users/addresses/1')
|
.put("/users/addresses/1")
|
||||||
.send({ address1: 'Updated St' });
|
.send({ address1: "Updated St" });
|
||||||
|
|
||||||
expect(response.status).toBe(403);
|
expect(response.status).toBe(403);
|
||||||
expect(response.body).toEqual({ error: 'Unauthorized' });
|
expect(response.body).toEqual({ error: "Unauthorized" });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle database errors', async () => {
|
it("should handle database errors", async () => {
|
||||||
mockUserAddressFindByPk.mockRejectedValue(new Error('Database error'));
|
mockUserAddressFindByPk.mockRejectedValue(new Error("Database error"));
|
||||||
|
|
||||||
const response = await request(app)
|
const response = await request(app)
|
||||||
.put('/users/addresses/1')
|
.put("/users/addresses/1")
|
||||||
.send({ address1: 'Updated St' });
|
.send({ address1: "Updated St" });
|
||||||
|
|
||||||
expect(response.status).toBe(500);
|
expect(response.status).toBe(500);
|
||||||
expect(response.body).toEqual({ error: 'Database error' });
|
expect(response.body).toEqual({ error: "Database error" });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('DELETE /addresses/:id', () => {
|
describe("DELETE /addresses/:id", () => {
|
||||||
const mockAddress = {
|
const mockAddress = {
|
||||||
id: 1,
|
id: 1,
|
||||||
userId: 1,
|
userId: 1,
|
||||||
address1: '123 Main St',
|
address1: "123 Main St",
|
||||||
destroy: jest.fn(),
|
destroy: jest.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -269,211 +247,210 @@ describe('Users Routes', () => {
|
|||||||
mockUserAddressFindByPk.mockResolvedValue(mockAddress);
|
mockUserAddressFindByPk.mockResolvedValue(mockAddress);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should delete user address', async () => {
|
it("should delete user address", async () => {
|
||||||
mockAddress.destroy.mockResolvedValue();
|
mockAddress.destroy.mockResolvedValue();
|
||||||
|
|
||||||
const response = await request(app)
|
const response = await request(app).delete("/users/addresses/1");
|
||||||
.delete('/users/addresses/1');
|
|
||||||
|
|
||||||
expect(response.status).toBe(204);
|
expect(response.status).toBe(204);
|
||||||
expect(mockAddress.destroy).toHaveBeenCalled();
|
expect(mockAddress.destroy).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return 404 for non-existent address', async () => {
|
it("should return 404 for non-existent address", async () => {
|
||||||
mockUserAddressFindByPk.mockResolvedValue(null);
|
mockUserAddressFindByPk.mockResolvedValue(null);
|
||||||
|
|
||||||
const response = await request(app)
|
const response = await request(app).delete("/users/addresses/999");
|
||||||
.delete('/users/addresses/999');
|
|
||||||
|
|
||||||
expect(response.status).toBe(404);
|
expect(response.status).toBe(404);
|
||||||
expect(response.body).toEqual({ error: 'Address not found' });
|
expect(response.body).toEqual({ error: "Address not found" });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return 403 for unauthorized user', async () => {
|
it("should return 403 for unauthorized user", async () => {
|
||||||
const unauthorizedAddress = { ...mockAddress, userId: 2 };
|
const unauthorizedAddress = { ...mockAddress, userId: 2 };
|
||||||
mockUserAddressFindByPk.mockResolvedValue(unauthorizedAddress);
|
mockUserAddressFindByPk.mockResolvedValue(unauthorizedAddress);
|
||||||
|
|
||||||
const response = await request(app)
|
const response = await request(app).delete("/users/addresses/1");
|
||||||
.delete('/users/addresses/1');
|
|
||||||
|
|
||||||
expect(response.status).toBe(403);
|
expect(response.status).toBe(403);
|
||||||
expect(response.body).toEqual({ error: 'Unauthorized' });
|
expect(response.body).toEqual({ error: "Unauthorized" });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle database errors', async () => {
|
it("should handle database errors", async () => {
|
||||||
mockUserAddressFindByPk.mockRejectedValue(new Error('Database error'));
|
mockUserAddressFindByPk.mockRejectedValue(new Error("Database error"));
|
||||||
|
|
||||||
const response = await request(app)
|
const response = await request(app).delete("/users/addresses/1");
|
||||||
.delete('/users/addresses/1');
|
|
||||||
|
|
||||||
expect(response.status).toBe(500);
|
expect(response.status).toBe(500);
|
||||||
expect(response.body).toEqual({ error: 'Database error' });
|
expect(response.body).toEqual({ error: "Database error" });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('GET /availability', () => {
|
describe("GET /availability", () => {
|
||||||
it('should get user availability settings', async () => {
|
it("should get user availability settings", async () => {
|
||||||
const mockUser = {
|
const mockUser = {
|
||||||
defaultAvailableAfter: '09:00',
|
defaultAvailableAfter: "09:00",
|
||||||
defaultAvailableBefore: '17:00',
|
defaultAvailableBefore: "17:00",
|
||||||
defaultSpecifyTimesPerDay: true,
|
defaultSpecifyTimesPerDay: true,
|
||||||
defaultWeeklyTimes: { monday: '09:00-17:00', tuesday: '10:00-16:00' },
|
defaultWeeklyTimes: { monday: "09:00-17:00", tuesday: "10:00-16:00" },
|
||||||
};
|
};
|
||||||
|
|
||||||
mockUserFindByPk.mockResolvedValue(mockUser);
|
mockUserFindByPk.mockResolvedValue(mockUser);
|
||||||
|
|
||||||
const response = await request(app)
|
const response = await request(app).get("/users/availability");
|
||||||
.get('/users/availability');
|
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
expect(response.body).toEqual({
|
expect(response.body).toEqual({
|
||||||
generalAvailableAfter: '09:00',
|
generalAvailableAfter: "09:00",
|
||||||
generalAvailableBefore: '17:00',
|
generalAvailableBefore: "17:00",
|
||||||
specifyTimesPerDay: true,
|
specifyTimesPerDay: true,
|
||||||
weeklyTimes: { monday: '09:00-17:00', tuesday: '10:00-16:00' },
|
weeklyTimes: { monday: "09:00-17:00", tuesday: "10:00-16:00" },
|
||||||
});
|
});
|
||||||
expect(mockUserFindByPk).toHaveBeenCalledWith(1, {
|
expect(mockUserFindByPk).toHaveBeenCalledWith(1, {
|
||||||
attributes: ['defaultAvailableAfter', 'defaultAvailableBefore', 'defaultSpecifyTimesPerDay', 'defaultWeeklyTimes']
|
attributes: [
|
||||||
|
"defaultAvailableAfter",
|
||||||
|
"defaultAvailableBefore",
|
||||||
|
"defaultSpecifyTimesPerDay",
|
||||||
|
"defaultWeeklyTimes",
|
||||||
|
],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle database errors', async () => {
|
it("should handle database errors", async () => {
|
||||||
mockUserFindByPk.mockRejectedValue(new Error('Database error'));
|
mockUserFindByPk.mockRejectedValue(new Error("Database error"));
|
||||||
|
|
||||||
const response = await request(app)
|
const response = await request(app).get("/users/availability");
|
||||||
.get('/users/availability');
|
|
||||||
|
|
||||||
expect(response.status).toBe(500);
|
expect(response.status).toBe(500);
|
||||||
expect(response.body).toEqual({ error: 'Database error' });
|
expect(response.body).toEqual({ error: "Database error" });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('PUT /availability', () => {
|
describe("PUT /availability", () => {
|
||||||
it('should update user availability settings', async () => {
|
it("should update user availability settings", async () => {
|
||||||
const availabilityData = {
|
const availabilityData = {
|
||||||
generalAvailableAfter: '08:00',
|
generalAvailableAfter: "08:00",
|
||||||
generalAvailableBefore: '18:00',
|
generalAvailableBefore: "18:00",
|
||||||
specifyTimesPerDay: false,
|
specifyTimesPerDay: false,
|
||||||
weeklyTimes: { monday: '08:00-18:00' },
|
weeklyTimes: { monday: "08:00-18:00" },
|
||||||
};
|
};
|
||||||
|
|
||||||
mockUserUpdate.mockResolvedValue([1]);
|
mockUserUpdate.mockResolvedValue([1]);
|
||||||
|
|
||||||
const response = await request(app)
|
const response = await request(app)
|
||||||
.put('/users/availability')
|
.put("/users/availability")
|
||||||
.send(availabilityData);
|
.send(availabilityData);
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
expect(response.body).toEqual({ message: 'Availability updated successfully' });
|
expect(response.body).toEqual({
|
||||||
expect(mockUserUpdate).toHaveBeenCalledWith({
|
message: "Availability updated successfully",
|
||||||
defaultAvailableAfter: '08:00',
|
|
||||||
defaultAvailableBefore: '18:00',
|
|
||||||
defaultSpecifyTimesPerDay: false,
|
|
||||||
defaultWeeklyTimes: { monday: '08:00-18:00' },
|
|
||||||
}, {
|
|
||||||
where: { id: 1 }
|
|
||||||
});
|
});
|
||||||
|
expect(mockUserUpdate).toHaveBeenCalledWith(
|
||||||
|
{
|
||||||
|
defaultAvailableAfter: "08:00",
|
||||||
|
defaultAvailableBefore: "18:00",
|
||||||
|
defaultSpecifyTimesPerDay: false,
|
||||||
|
defaultWeeklyTimes: { monday: "08:00-18:00" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
where: { id: 1 },
|
||||||
|
}
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle database errors', async () => {
|
it("should handle database errors", async () => {
|
||||||
mockUserUpdate.mockRejectedValue(new Error('Database error'));
|
mockUserUpdate.mockRejectedValue(new Error("Database error"));
|
||||||
|
|
||||||
const response = await request(app)
|
const response = await request(app).put("/users/availability").send({
|
||||||
.put('/users/availability')
|
generalAvailableAfter: "08:00",
|
||||||
.send({
|
generalAvailableBefore: "18:00",
|
||||||
generalAvailableAfter: '08:00',
|
});
|
||||||
generalAvailableBefore: '18:00',
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(response.status).toBe(500);
|
expect(response.status).toBe(500);
|
||||||
expect(response.body).toEqual({ error: 'Database error' });
|
expect(response.body).toEqual({ error: "Database error" });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('GET /:id', () => {
|
describe("GET /:id", () => {
|
||||||
it('should get public user profile by ID', async () => {
|
it("should get public user profile by ID", async () => {
|
||||||
const mockUser = {
|
const mockUser = {
|
||||||
id: 2,
|
id: 2,
|
||||||
firstName: 'Jane',
|
firstName: "Jane",
|
||||||
lastName: 'Smith',
|
lastName: "Smith",
|
||||||
username: 'janesmith',
|
username: "janesmith",
|
||||||
imageFilename: 'jane.jpg',
|
imageFilename: "jane.jpg",
|
||||||
};
|
};
|
||||||
|
|
||||||
mockUserFindByPk.mockResolvedValue(mockUser);
|
mockUserFindByPk.mockResolvedValue(mockUser);
|
||||||
|
|
||||||
const response = await request(app)
|
const response = await request(app).get("/users/2");
|
||||||
.get('/users/2');
|
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
expect(response.body).toEqual(mockUser);
|
expect(response.body).toEqual(mockUser);
|
||||||
expect(mockUserFindByPk).toHaveBeenCalledWith('2', {
|
expect(mockUserFindByPk).toHaveBeenCalledWith("2", {
|
||||||
attributes: { exclude: ['password', 'email', 'phone', 'address'] }
|
attributes: { exclude: ["password", "email", "phone", "address"] },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return 404 for non-existent user', async () => {
|
it("should return 404 for non-existent user", async () => {
|
||||||
mockUserFindByPk.mockResolvedValue(null);
|
mockUserFindByPk.mockResolvedValue(null);
|
||||||
|
|
||||||
const response = await request(app)
|
const response = await request(app).get("/users/999");
|
||||||
.get('/users/999');
|
|
||||||
|
|
||||||
expect(response.status).toBe(404);
|
expect(response.status).toBe(404);
|
||||||
expect(response.body).toEqual({ error: 'User not found' });
|
expect(response.body).toEqual({ error: "User not found" });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle database errors', async () => {
|
it("should handle database errors", async () => {
|
||||||
mockUserFindByPk.mockRejectedValue(new Error('Database error'));
|
mockUserFindByPk.mockRejectedValue(new Error("Database error"));
|
||||||
|
|
||||||
const response = await request(app)
|
const response = await request(app).get("/users/2");
|
||||||
.get('/users/2');
|
|
||||||
|
|
||||||
expect(response.status).toBe(500);
|
expect(response.status).toBe(500);
|
||||||
expect(response.body).toEqual({ error: 'Database error' });
|
expect(response.body).toEqual({ error: "Database error" });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('PUT /profile', () => {
|
describe("PUT /profile", () => {
|
||||||
const mockUpdatedUser = {
|
const mockUpdatedUser = {
|
||||||
id: 1,
|
id: 1,
|
||||||
firstName: 'Updated',
|
firstName: "Updated",
|
||||||
lastName: 'User',
|
lastName: "User",
|
||||||
email: 'updated@example.com',
|
email: "updated@example.com",
|
||||||
phone: '555-9999',
|
phone: "555-9999",
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockUserFindByPk.mockResolvedValue(mockUpdatedUser);
|
mockUserFindByPk.mockResolvedValue(mockUpdatedUser);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should update user profile', async () => {
|
it("should update user profile", async () => {
|
||||||
const profileData = {
|
const profileData = {
|
||||||
firstName: 'Updated',
|
firstName: "Updated",
|
||||||
lastName: 'User',
|
lastName: "User",
|
||||||
email: 'updated@example.com',
|
email: "updated@example.com",
|
||||||
phone: '555-9999',
|
phone: "555-9999",
|
||||||
address1: '123 New St',
|
address1: "123 New St",
|
||||||
city: 'New City',
|
city: "New City",
|
||||||
};
|
};
|
||||||
|
|
||||||
const response = await request(app)
|
const response = await request(app)
|
||||||
.put('/users/profile')
|
.put("/users/profile")
|
||||||
.send(profileData);
|
.send(profileData);
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
expect(response.body).toEqual(mockUpdatedUser);
|
expect(response.body).toEqual(mockUpdatedUser);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should exclude empty email from update', async () => {
|
it("should exclude empty email from update", async () => {
|
||||||
const profileData = {
|
const profileData = {
|
||||||
firstName: 'Updated',
|
firstName: "Updated",
|
||||||
lastName: 'User',
|
lastName: "User",
|
||||||
email: '',
|
email: "",
|
||||||
phone: '555-9999',
|
phone: "555-9999",
|
||||||
};
|
};
|
||||||
|
|
||||||
const response = await request(app)
|
const response = await request(app)
|
||||||
.put('/users/profile')
|
.put("/users/profile")
|
||||||
.send(profileData);
|
.send(profileData);
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
@@ -481,178 +458,51 @@ describe('Users Routes', () => {
|
|||||||
// (This would need to check the actual update call if we spy on req.user.update)
|
// (This would need to check the actual update call if we spy on req.user.update)
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle validation errors', async () => {
|
it("should handle validation errors", async () => {
|
||||||
const mockValidationError = new Error('Validation error');
|
const mockValidationError = new Error("Validation error");
|
||||||
mockValidationError.errors = [
|
mockValidationError.errors = [
|
||||||
{ path: 'email', message: 'Invalid email format' }
|
{ path: "email", message: "Invalid email format" },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Mock req.user.update to throw validation error
|
// Mock req.user.update to throw validation error
|
||||||
const { authenticateToken } = require('../../../middleware/auth');
|
const { authenticateToken } = require("../../../middleware/auth");
|
||||||
authenticateToken.mockImplementation((req, res, next) => {
|
authenticateToken.mockImplementation((req, res, next) => {
|
||||||
req.user = {
|
req.user = {
|
||||||
id: 1,
|
id: 1,
|
||||||
update: jest.fn().mockRejectedValue(mockValidationError)
|
update: jest.fn().mockRejectedValue(mockValidationError),
|
||||||
};
|
};
|
||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
|
|
||||||
const response = await request(app)
|
const response = await request(app).put("/users/profile").send({
|
||||||
.put('/users/profile')
|
firstName: "Test",
|
||||||
.send({
|
email: "invalid-email",
|
||||||
firstName: 'Test',
|
});
|
||||||
email: 'invalid-email',
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(response.status).toBe(500);
|
expect(response.status).toBe(500);
|
||||||
expect(response.body).toEqual({
|
expect(response.body).toEqual({
|
||||||
error: 'Validation error',
|
error: "Validation error",
|
||||||
details: [{ field: 'email', message: 'Invalid email format' }]
|
details: [{ field: "email", message: "Invalid email format" }],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle general database errors', async () => {
|
it("should handle general database errors", async () => {
|
||||||
// Reset the authenticateToken mock to use default user
|
// Reset the authenticateToken mock to use default user
|
||||||
const { authenticateToken } = require('../../../middleware/auth');
|
const { authenticateToken } = require("../../../middleware/auth");
|
||||||
authenticateToken.mockImplementation((req, res, next) => {
|
authenticateToken.mockImplementation((req, res, next) => {
|
||||||
req.user = {
|
req.user = {
|
||||||
id: 1,
|
id: 1,
|
||||||
update: jest.fn().mockRejectedValue(new Error('Database error'))
|
update: jest.fn().mockRejectedValue(new Error("Database error")),
|
||||||
};
|
};
|
||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
|
|
||||||
const response = await request(app)
|
const response = await request(app).put("/users/profile").send({
|
||||||
.put('/users/profile')
|
firstName: "Test",
|
||||||
.send({
|
});
|
||||||
firstName: 'Test',
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(response.status).toBe(500);
|
expect(response.status).toBe(500);
|
||||||
expect(response.body).toEqual({ error: 'Database error' });
|
expect(response.body).toEqual({ error: "Database error" });
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('POST /profile/image', () => {
|
|
||||||
const mockUser = {
|
|
||||||
id: 1,
|
|
||||||
imageFilename: 'old-image.jpg',
|
|
||||||
update: jest.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
mockUserFindByPk.mockResolvedValue(mockUser);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should upload profile image successfully', async () => {
|
|
||||||
mockUser.update.mockResolvedValue();
|
|
||||||
fs.unlink.mockResolvedValue();
|
|
||||||
|
|
||||||
const response = await request(app)
|
|
||||||
.post('/users/profile/image');
|
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
|
||||||
expect(response.body).toEqual({
|
|
||||||
message: 'Profile image uploaded successfully',
|
|
||||||
filename: 'test-profile.jpg',
|
|
||||||
imageUrl: '/uploads/profiles/test-profile.jpg'
|
|
||||||
});
|
|
||||||
expect(fs.unlink).toHaveBeenCalled(); // Old image deleted
|
|
||||||
expect(mockUser.update).toHaveBeenCalledWith({
|
|
||||||
imageFilename: 'test-profile.jpg'
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle upload errors', async () => {
|
|
||||||
uploadProfileImage.mockImplementation((req, res, callback) => {
|
|
||||||
callback(new Error('File too large'));
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await request(app)
|
|
||||||
.post('/users/profile/image');
|
|
||||||
|
|
||||||
expect(response.status).toBe(400);
|
|
||||||
expect(response.body).toEqual({ error: 'File too large' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle missing file', async () => {
|
|
||||||
uploadProfileImage.mockImplementation((req, res, callback) => {
|
|
||||||
req.file = null;
|
|
||||||
callback(null);
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await request(app)
|
|
||||||
.post('/users/profile/image');
|
|
||||||
|
|
||||||
expect(response.status).toBe(400);
|
|
||||||
expect(response.body).toEqual({ error: 'No file uploaded' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle database update errors', async () => {
|
|
||||||
// Mock upload to succeed but database update to fail
|
|
||||||
uploadProfileImage.mockImplementation((req, res, callback) => {
|
|
||||||
req.file = { filename: 'test-profile.jpg' };
|
|
||||||
callback(null);
|
|
||||||
});
|
|
||||||
|
|
||||||
const userWithError = {
|
|
||||||
...mockUser,
|
|
||||||
update: jest.fn().mockRejectedValue(new Error('Database error'))
|
|
||||||
};
|
|
||||||
mockUserFindByPk.mockResolvedValue(userWithError);
|
|
||||||
|
|
||||||
const response = await request(app)
|
|
||||||
.post('/users/profile/image');
|
|
||||||
|
|
||||||
expect(response.status).toBe(500);
|
|
||||||
expect(response.body).toEqual({ error: 'Failed to update profile image' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle case when user has no existing profile image', async () => {
|
|
||||||
// Mock upload to succeed
|
|
||||||
uploadProfileImage.mockImplementation((req, res, callback) => {
|
|
||||||
req.file = { filename: 'test-profile.jpg' };
|
|
||||||
callback(null);
|
|
||||||
});
|
|
||||||
|
|
||||||
const userWithoutImage = {
|
|
||||||
id: 1,
|
|
||||||
imageFilename: null,
|
|
||||||
update: jest.fn().mockResolvedValue()
|
|
||||||
};
|
|
||||||
mockUserFindByPk.mockResolvedValue(userWithoutImage);
|
|
||||||
|
|
||||||
const response = await request(app)
|
|
||||||
.post('/users/profile/image');
|
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
|
||||||
expect(fs.unlink).not.toHaveBeenCalled(); // No old image to delete
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should continue if old image deletion fails', async () => {
|
|
||||||
// Mock upload to succeed
|
|
||||||
uploadProfileImage.mockImplementation((req, res, callback) => {
|
|
||||||
req.file = { filename: 'test-profile.jpg' };
|
|
||||||
callback(null);
|
|
||||||
});
|
|
||||||
|
|
||||||
const userWithImage = {
|
|
||||||
id: 1,
|
|
||||||
imageFilename: 'old-image.jpg',
|
|
||||||
update: jest.fn().mockResolvedValue()
|
|
||||||
};
|
|
||||||
mockUserFindByPk.mockResolvedValue(userWithImage);
|
|
||||||
fs.unlink.mockRejectedValue(new Error('File not found'));
|
|
||||||
|
|
||||||
const response = await request(app)
|
|
||||||
.post('/users/profile/image');
|
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
|
||||||
expect(response.body).toEqual({
|
|
||||||
message: 'Profile image uploaded successfully',
|
|
||||||
filename: 'test-profile.jpg',
|
|
||||||
imageUrl: '/uploads/profiles/test-profile.jpg'
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
112
backend/tests/unit/utils/s3KeyValidator.test.js
Normal file
112
backend/tests/unit/utils/s3KeyValidator.test.js
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
const { validateS3Keys } = require('../../../utils/s3KeyValidator');
|
||||||
|
|
||||||
|
describe('S3 Key Validator', () => {
|
||||||
|
describe('validateS3Keys', () => {
|
||||||
|
const validUuid1 = '550e8400-e29b-41d4-a716-446655440000';
|
||||||
|
const validUuid2 = '660e8400-e29b-41d4-a716-446655440001';
|
||||||
|
|
||||||
|
test('should accept valid arrays of keys', () => {
|
||||||
|
const keys = [
|
||||||
|
`items/${validUuid1}.jpg`,
|
||||||
|
`items/${validUuid2}.png`,
|
||||||
|
];
|
||||||
|
expect(validateS3Keys(keys, 'items')).toEqual({ valid: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should accept valid keys for each folder', () => {
|
||||||
|
expect(validateS3Keys([`profiles/${validUuid1}.jpg`], 'profiles')).toEqual({ valid: true });
|
||||||
|
expect(validateS3Keys([`items/${validUuid1}.png`], 'items')).toEqual({ valid: true });
|
||||||
|
expect(validateS3Keys([`messages/${validUuid1}.gif`], 'messages')).toEqual({ valid: true });
|
||||||
|
expect(validateS3Keys([`forum/${validUuid1}.webp`], 'forum')).toEqual({ valid: true });
|
||||||
|
expect(validateS3Keys([`condition-checks/${validUuid1}.jpeg`], 'condition-checks')).toEqual({ valid: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should accept different valid extensions', () => {
|
||||||
|
const extensions = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
|
||||||
|
for (const ext of extensions) {
|
||||||
|
expect(validateS3Keys([`items/${validUuid1}.${ext}`], 'items')).toEqual({ valid: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should be case-insensitive for extensions', () => {
|
||||||
|
expect(validateS3Keys([`items/${validUuid1}.JPG`], 'items')).toEqual({ valid: true });
|
||||||
|
expect(validateS3Keys([`items/${validUuid1}.PNG`], 'items')).toEqual({ valid: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should accept empty arrays', () => {
|
||||||
|
expect(validateS3Keys([], 'items')).toEqual({ valid: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should reject non-array input', () => {
|
||||||
|
const result = validateS3Keys('not-an-array', 'items');
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
expect(result.error).toBe('Keys must be an array');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should reject arrays exceeding maxKeys', () => {
|
||||||
|
const keys = Array(6).fill(`items/${validUuid1}.jpg`);
|
||||||
|
const result = validateS3Keys(keys, 'items', { maxKeys: 5 });
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
expect(result.error).toContain('Maximum 5 keys allowed');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should reject duplicate keys', () => {
|
||||||
|
const keys = [
|
||||||
|
`items/${validUuid1}.jpg`,
|
||||||
|
`items/${validUuid1}.jpg`,
|
||||||
|
];
|
||||||
|
const result = validateS3Keys(keys, 'items');
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
expect(result.error).toBe('Duplicate keys not allowed');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should reject keys with wrong folder prefix', () => {
|
||||||
|
const result = validateS3Keys([`profiles/${validUuid1}.jpg`], 'items');
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
expect(result.invalidKeys).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should reject invalid UUID format', () => {
|
||||||
|
const result = validateS3Keys(['items/invalid-uuid.jpg'], 'items');
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should reject non-image extensions', () => {
|
||||||
|
const result = validateS3Keys([`items/${validUuid1}.exe`], 'items');
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should reject path traversal attempts', () => {
|
||||||
|
const result = validateS3Keys([`../items/${validUuid1}.jpg`], 'items');
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should reject empty or null keys in array', () => {
|
||||||
|
expect(validateS3Keys([''], 'items').valid).toBe(false);
|
||||||
|
expect(validateS3Keys([null], 'items').valid).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should reject invalid folder names', () => {
|
||||||
|
const result = validateS3Keys([`items/${validUuid1}.jpg`], 'invalid-folder');
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return invalid keys in error response', () => {
|
||||||
|
const keys = [
|
||||||
|
`items/${validUuid1}.jpg`,
|
||||||
|
'invalid-key.jpg',
|
||||||
|
`items/${validUuid2}.exe`,
|
||||||
|
];
|
||||||
|
const result = validateS3Keys(keys, 'items');
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
expect(result.invalidKeys).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should use default maxKeys of 20', () => {
|
||||||
|
const keys = Array(20).fill(0).map((_, i) =>
|
||||||
|
`items/550e8400-e29b-41d4-a716-44665544${String(i).padStart(4, '0')}.jpg`
|
||||||
|
);
|
||||||
|
expect(validateS3Keys(keys, 'items')).toEqual({ valid: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
102
backend/utils/s3KeyValidator.js
Normal file
102
backend/utils/s3KeyValidator.js
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
/**
|
||||||
|
* S3 Key Validation Utility
|
||||||
|
* Validates that user-supplied S3 keys match expected formats
|
||||||
|
* to prevent IDOR and arbitrary data injection attacks
|
||||||
|
*/
|
||||||
|
|
||||||
|
// UUID v4 regex pattern
|
||||||
|
const UUID_PATTERN =
|
||||||
|
"[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}";
|
||||||
|
|
||||||
|
// Allowed image extensions
|
||||||
|
const ALLOWED_EXTENSIONS = ["jpg", "jpeg", "png", "gif", "webp"];
|
||||||
|
|
||||||
|
// Valid S3 folders
|
||||||
|
const VALID_FOLDERS = [
|
||||||
|
"profiles",
|
||||||
|
"items",
|
||||||
|
"messages",
|
||||||
|
"forum",
|
||||||
|
"condition-checks",
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build regex pattern for a specific folder
|
||||||
|
* @param {string} folder - The S3 folder name
|
||||||
|
* @returns {RegExp}
|
||||||
|
*/
|
||||||
|
function buildKeyPattern(folder) {
|
||||||
|
const extPattern = ALLOWED_EXTENSIONS.join("|");
|
||||||
|
return new RegExp(`^${folder}/${UUID_PATTERN}\\.(${extPattern})$`, "i");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a single S3 key
|
||||||
|
* @param {string} key - The S3 key to validate
|
||||||
|
* @param {string} folder - Expected folder (profiles, items, messages, forum, condition-checks)
|
||||||
|
* @returns {{ valid: boolean, error?: string }}
|
||||||
|
*/
|
||||||
|
function validateS3Key(key, folder) {
|
||||||
|
if (!key || typeof key !== "string") {
|
||||||
|
return { valid: false, error: "Key must be a non-empty string" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!VALID_FOLDERS.includes(folder)) {
|
||||||
|
return { valid: false, error: `Invalid folder` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const pattern = buildKeyPattern(folder);
|
||||||
|
if (!pattern.test(key)) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: `Invalid key format`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate an array of S3 keys
|
||||||
|
* @param {Array} keys - Array of S3 keys to validate
|
||||||
|
* @param {string} folder - Expected folder (profiles, items, messages, forum, condition-checks)
|
||||||
|
* @param {Object} options - Validation options
|
||||||
|
* @param {number} options.maxKeys - Maximum number of keys allowed
|
||||||
|
* @returns {{ valid: boolean, error?: string, invalidKeys?: Array }}
|
||||||
|
*/
|
||||||
|
function validateS3Keys(keys, folder, options = {}) {
|
||||||
|
const { maxKeys = 20 } = options;
|
||||||
|
|
||||||
|
if (!Array.isArray(keys)) {
|
||||||
|
return { valid: false, error: "Keys must be an array" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (keys.length > maxKeys) {
|
||||||
|
return { valid: false, error: `Maximum ${maxKeys} keys allowed` };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (keys.length === 0) {
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const uniqueKeys = new Set(keys);
|
||||||
|
if (uniqueKeys.size !== keys.length) {
|
||||||
|
return { valid: false, error: "Duplicate keys not allowed" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const invalidKeys = [];
|
||||||
|
for (const key of keys) {
|
||||||
|
const result = validateS3Key(key, folder);
|
||||||
|
if (!result.valid) {
|
||||||
|
invalidKeys.push({ key, error: result.error });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (invalidKeys.length > 0) {
|
||||||
|
return { valid: false, error: "Invalid S3 key format", invalidKeys };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { validateS3Keys };
|
||||||
@@ -87,7 +87,6 @@ const CommentForm: React.FC<CommentFormProps> = ({
|
|||||||
imagePreviews={imagePreviews}
|
imagePreviews={imagePreviews}
|
||||||
onImageChange={handleImageChange}
|
onImageChange={handleImageChange}
|
||||||
onRemoveImage={handleRemoveImage}
|
onRemoveImage={handleRemoveImage}
|
||||||
maxImages={3}
|
|
||||||
compact={true}
|
compact={true}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { conditionCheckAPI } from "../services/api";
|
import { conditionCheckAPI } from "../services/api";
|
||||||
|
import { IMAGE_LIMITS } from "../config/imageLimits";
|
||||||
|
|
||||||
interface ConditionCheckModalProps {
|
interface ConditionCheckModalProps {
|
||||||
show: boolean;
|
show: boolean;
|
||||||
@@ -59,8 +60,8 @@ const ConditionCheckModal: React.FC<ConditionCheckModalProps> = ({
|
|||||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
if (e.target.files) {
|
if (e.target.files) {
|
||||||
const selectedFiles = Array.from(e.target.files);
|
const selectedFiles = Array.from(e.target.files);
|
||||||
if (selectedFiles.length + photos.length > 20) {
|
if (selectedFiles.length + photos.length > IMAGE_LIMITS.conditionChecks) {
|
||||||
setError("Maximum 20 photos allowed");
|
setError(`Maximum ${IMAGE_LIMITS.conditionChecks} photos allowed`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setPhotos((prev) => [...prev, ...selectedFiles]);
|
setPhotos((prev) => [...prev, ...selectedFiles]);
|
||||||
@@ -153,7 +154,7 @@ const ConditionCheckModal: React.FC<ConditionCheckModalProps> = ({
|
|||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
<label className="form-label">
|
<label className="form-label">
|
||||||
Photos <span className="text-danger">*</span>
|
Photos <span className="text-danger">*</span>
|
||||||
<small className="text-muted ms-2">(Maximum 20 photos)</small>
|
<small className="text-muted ms-2">(Maximum {IMAGE_LIMITS.conditionChecks} photos)</small>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { IMAGE_LIMITS } from '../config/imageLimits';
|
||||||
|
|
||||||
interface ForumImageUploadProps {
|
interface ForumImageUploadProps {
|
||||||
imageFiles: File[];
|
imageFiles: File[];
|
||||||
imagePreviews: string[];
|
imagePreviews: string[];
|
||||||
onImageChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
onImageChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||||
onRemoveImage: (index: number) => void;
|
onRemoveImage: (index: number) => void;
|
||||||
maxImages?: number;
|
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -14,9 +14,10 @@ const ForumImageUpload: React.FC<ForumImageUploadProps> = ({
|
|||||||
imagePreviews,
|
imagePreviews,
|
||||||
onImageChange,
|
onImageChange,
|
||||||
onRemoveImage,
|
onRemoveImage,
|
||||||
maxImages = 5,
|
|
||||||
compact = false
|
compact = false
|
||||||
}) => {
|
}) => {
|
||||||
|
const maxImages = IMAGE_LIMITS.forum;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={compact ? 'mb-2' : 'mb-3'}>
|
<div className={compact ? 'mb-2' : 'mb-3'}>
|
||||||
<label className="form-label mb-1">
|
<label className="form-label mb-1">
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { IMAGE_LIMITS } from '../config/imageLimits';
|
||||||
|
|
||||||
interface ImageUploadProps {
|
interface ImageUploadProps {
|
||||||
imageFiles: File[];
|
imageFiles: File[];
|
||||||
@@ -15,12 +16,14 @@ const ImageUpload: React.FC<ImageUploadProps> = ({
|
|||||||
onRemoveImage,
|
onRemoveImage,
|
||||||
error
|
error
|
||||||
}) => {
|
}) => {
|
||||||
|
const maxImages = IMAGE_LIMITS.items;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="card mb-4">
|
<div className="card mb-4">
|
||||||
<div className="card-body">
|
<div className="card-body">
|
||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
<label className="form-label mb-0">
|
<label className="form-label mb-0">
|
||||||
Upload Images (Max 5)
|
Upload Images (Max {maxImages})
|
||||||
</label>
|
</label>
|
||||||
<div className="form-text mb-2">
|
<div className="form-text mb-2">
|
||||||
Have pictures of everything that's included
|
Have pictures of everything that's included
|
||||||
@@ -31,7 +34,7 @@ const ImageUpload: React.FC<ImageUploadProps> = ({
|
|||||||
onChange={onImageChange}
|
onChange={onImageChange}
|
||||||
accept="image/*"
|
accept="image/*"
|
||||||
multiple
|
multiple
|
||||||
disabled={imageFiles.length >= 5}
|
disabled={imageFiles.length >= maxImages}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
12
frontend/src/config/imageLimits.ts
Normal file
12
frontend/src/config/imageLimits.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
/**
|
||||||
|
* Image upload limits configuration
|
||||||
|
* Keep in sync with backend/config/imageLimits.js
|
||||||
|
*/
|
||||||
|
export const IMAGE_LIMITS = {
|
||||||
|
items: 10,
|
||||||
|
forum: 10,
|
||||||
|
conditionChecks: 10,
|
||||||
|
damageReports: 10,
|
||||||
|
profile: 1,
|
||||||
|
messages: 1,
|
||||||
|
} as const;
|
||||||
@@ -380,7 +380,6 @@ const CreateForumPost: React.FC = () => {
|
|||||||
imagePreviews={imagePreviews}
|
imagePreviews={imagePreviews}
|
||||||
onImageChange={handleImageChange}
|
onImageChange={handleImageChange}
|
||||||
onRemoveImage={handleRemoveImage}
|
onRemoveImage={handleRemoveImage}
|
||||||
maxImages={5}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import DeliveryOptions from "../components/DeliveryOptions";
|
|||||||
import PricingForm from "../components/PricingForm";
|
import PricingForm from "../components/PricingForm";
|
||||||
import RulesForm from "../components/RulesForm";
|
import RulesForm from "../components/RulesForm";
|
||||||
import { Address } from "../types";
|
import { Address } from "../types";
|
||||||
|
import { IMAGE_LIMITS } from "../config/imageLimits";
|
||||||
|
|
||||||
interface ItemFormData {
|
interface ItemFormData {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -356,9 +357,8 @@ const CreateItem: React.FC = () => {
|
|||||||
const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const files = Array.from(e.target.files || []);
|
const files = Array.from(e.target.files || []);
|
||||||
|
|
||||||
// Limit to 5 images
|
if (imageFiles.length + files.length > IMAGE_LIMITS.items) {
|
||||||
if (imageFiles.length + files.length > 5) {
|
setError(`You can upload a maximum of ${IMAGE_LIMITS.items} images`);
|
||||||
setError("You can upload a maximum of 5 images");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import LocationForm from "../components/LocationForm";
|
|||||||
import DeliveryOptions from "../components/DeliveryOptions";
|
import DeliveryOptions from "../components/DeliveryOptions";
|
||||||
import PricingForm from "../components/PricingForm";
|
import PricingForm from "../components/PricingForm";
|
||||||
import RulesForm from "../components/RulesForm";
|
import RulesForm from "../components/RulesForm";
|
||||||
|
import { IMAGE_LIMITS } from "../config/imageLimits";
|
||||||
|
|
||||||
interface ItemFormData {
|
interface ItemFormData {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -346,9 +347,8 @@ const EditItem: React.FC = () => {
|
|||||||
const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const files = Array.from(e.target.files || []);
|
const files = Array.from(e.target.files || []);
|
||||||
|
|
||||||
// Limit to 5 images total
|
if (imagePreviews.length + files.length > IMAGE_LIMITS.items) {
|
||||||
if (imagePreviews.length + files.length > 5) {
|
setError(`You can upload a maximum of ${IMAGE_LIMITS.items} images`);
|
||||||
setError("You can upload a maximum of 5 images");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user