This commit is contained in:
jackiettran
2025-12-11 20:05:18 -05:00
parent 11593606aa
commit b0268a2fb7
28 changed files with 2578 additions and 432 deletions

View File

@@ -1,77 +1,56 @@
const express = require("express");
const multer = require("multer");
const { authenticateToken } = require("../middleware/auth");
const ConditionCheckService = require("../services/conditionCheckService");
const logger = require("../utils/logger");
const router = express.Router();
// Configure multer for photo uploads
const upload = multer({
dest: "uploads/condition-checks/",
limits: {
fileSize: 10 * 1024 * 1024, // 10MB limit
files: 20, // Maximum 20 files
},
fileFilter: (req, file, cb) => {
// Accept only image files
if (file.mimetype.startsWith("image/")) {
cb(null, true);
} else {
cb(new Error("Only image files are allowed"), false);
}
},
});
// Submit a condition check
router.post(
"/:rentalId",
authenticateToken,
upload.array("imageFilenames"),
async (req, res) => {
try {
const { rentalId } = req.params;
const { checkType, notes } = req.body;
const userId = req.user.id;
router.post("/:rentalId", authenticateToken, async (req, res) => {
try {
const { rentalId } = req.params;
const { checkType, notes, imageFilenames: rawImageFilenames } = req.body;
const userId = req.user.id;
// Get uploaded file paths
const imageFilenames = req.files ? req.files.map((file) => file.path) : [];
// Ensure imageFilenames is an array (S3 keys)
const imageFilenames = Array.isArray(rawImageFilenames)
? rawImageFilenames
: [];
const conditionCheck = await ConditionCheckService.submitConditionCheck(
rentalId,
checkType,
userId,
imageFilenames,
notes
);
const conditionCheck = await ConditionCheckService.submitConditionCheck(
rentalId,
checkType,
userId,
imageFilenames,
notes
);
const reqLogger = logger.withRequestId(req.id);
reqLogger.info("Condition check submitted", {
rentalId,
checkType,
userId,
photoCount: imageFilenames.length,
});
const reqLogger = logger.withRequestId(req.id);
reqLogger.info("Condition check submitted", {
rentalId,
checkType,
userId,
photoCount: imageFilenames.length,
});
res.status(201).json({
success: true,
conditionCheck,
});
} catch (error) {
const reqLogger = logger.withRequestId(req.id);
reqLogger.error("Error submitting condition check", {
error: error.message,
rentalId: req.params.rentalId,
userId: req.user?.id,
});
res.status(201).json({
success: true,
conditionCheck,
});
} catch (error) {
const reqLogger = logger.withRequestId(req.id);
reqLogger.error("Error submitting condition check", {
error: error.message,
rentalId: req.params.rentalId,
userId: req.user?.id,
});
res.status(400).json({
success: false,
error: error.message,
});
}
res.status(400).json({
success: false,
error: error.message,
});
}
);
});
// Get condition checks for a rental
router.get("/:rentalId", authenticateToken, async (req, res) => {

View File

@@ -2,7 +2,6 @@ const express = require('express');
const { Op } = require('sequelize');
const { ForumPost, ForumComment, PostTag, User } = require('../models');
const { authenticateToken, requireAdmin, optionalAuth } = require('../middleware/auth');
const { uploadForumPostImages, uploadForumCommentImages } = require('../middleware/upload');
const logger = require('../utils/logger');
const emailServices = require('../services/email');
const googleMapsService = require('../services/googleMapsService');
@@ -238,21 +237,12 @@ router.get('/posts/:id', optionalAuth, async (req, res, next) => {
});
// POST /api/forum/posts - Create new post
router.post('/posts', authenticateToken, uploadForumPostImages, async (req, res, next) => {
router.post('/posts', authenticateToken, async (req, res, next) => {
try {
let { title, content, category, tags, zipCode, latitude: providedLat, longitude: providedLng } = req.body;
let { title, content, category, tags, zipCode, latitude: providedLat, longitude: providedLng, imageFilenames } = req.body;
// Parse tags if they come as JSON string (from FormData)
if (typeof tags === 'string') {
try {
tags = JSON.parse(tags);
} catch (e) {
tags = [];
}
}
// Extract image filenames if uploaded
const imageFilenames = req.files ? req.files.map(file => file.filename) : [];
// Ensure imageFilenames is an array
imageFilenames = Array.isArray(imageFilenames) ? imageFilenames : [];
// Initialize location fields
let latitude = null;
@@ -913,9 +903,11 @@ router.patch('/posts/:id/accept-answer', authenticateToken, async (req, res, nex
});
// POST /api/forum/posts/:id/comments - Add comment/reply
router.post('/posts/:id/comments', authenticateToken, uploadForumCommentImages, async (req, res, next) => {
router.post('/posts/:id/comments', authenticateToken, async (req, res, next) => {
try {
const { content, parentCommentId } = req.body;
// Support both parentId (new) and parentCommentId (legacy) for backwards compatibility
const { content, parentId, parentCommentId, imageFilenames: rawImageFilenames } = req.body;
const parentIdResolved = parentId || parentCommentId;
const post = await ForumPost.findByPk(req.params.id);
if (!post) {
@@ -928,21 +920,21 @@ router.post('/posts/:id/comments', authenticateToken, uploadForumCommentImages,
}
// Validate parent comment if provided
if (parentCommentId) {
const parentComment = await ForumComment.findByPk(parentCommentId);
if (parentIdResolved) {
const parentComment = await ForumComment.findByPk(parentIdResolved);
if (!parentComment || parentComment.postId !== post.id) {
return res.status(400).json({ error: 'Invalid parent comment' });
}
}
// Extract image filenames if uploaded
const imageFilenames = req.files ? req.files.map(file => file.filename) : [];
// Ensure imageFilenames is an array
const imageFilenames = Array.isArray(rawImageFilenames) ? rawImageFilenames : [];
const comment = await ForumComment.create({
postId: req.params.id,
authorId: req.user.id,
content,
parentCommentId: parentCommentId || null,
parentCommentId: parentIdResolved || null,
imageFilenames
});

View File

@@ -2,7 +2,6 @@ const express = require('express');
const helmet = require('helmet');
const { Message, User } = require('../models');
const { authenticateToken } = require('../middleware/auth');
const { uploadMessageImage } = require('../middleware/upload');
const logger = require('../utils/logger');
const { emitNewMessage, emitMessageRead } = require('../sockets/messageSocket');
const { Op } = require('sequelize');
@@ -237,9 +236,9 @@ router.get('/:id', authenticateToken, async (req, res, next) => {
});
// Send a new message
router.post('/', authenticateToken, uploadMessageImage, async (req, res, next) => {
router.post('/', authenticateToken, async (req, res, next) => {
try {
const { receiverId, content } = req.body;
const { receiverId, content, imageFilename } = req.body;
// Check if receiver exists
const receiver = await User.findByPk(receiverId);
@@ -252,14 +251,11 @@ router.post('/', authenticateToken, uploadMessageImage, async (req, res, next) =
return res.status(400).json({ error: 'Cannot send messages to yourself' });
}
// Extract image filename if uploaded
const imageFilename = req.file ? req.file.filename : null;
const message = await Message.create({
senderId: req.user.id,
receiverId,
content,
imageFilename
imageFilename: imageFilename || null
});
const messageWithSender = await Message.findByPk(message.id, {

214
backend/routes/upload.js Normal file
View File

@@ -0,0 +1,214 @@
const express = require("express");
const router = express.Router();
const { authenticateToken } = require("../middleware/auth");
const { uploadPresignLimiter } = require("../middleware/rateLimiter");
const s3Service = require("../services/s3Service");
const S3OwnershipService = require("../services/s3OwnershipService");
const logger = require("../utils/logger");
const MAX_BATCH_SIZE = 20;
/**
* Middleware to check if S3 is enabled
*/
const requireS3Enabled = (req, res, next) => {
if (!s3Service.isEnabled()) {
return res.status(503).json({
error: "File upload service is not available",
});
}
next();
};
/**
* POST /api/upload/presign
* Get a presigned URL for uploading a single file to S3
*/
router.post(
"/presign",
authenticateToken,
requireS3Enabled,
uploadPresignLimiter,
async (req, res, next) => {
try {
const { uploadType, contentType, fileName, fileSize } = req.body;
// Validate required fields
if (!uploadType || !contentType || !fileName || !fileSize) {
return res.status(400).json({ error: "Missing required fields" });
}
const result = await s3Service.getPresignedUploadUrl(
uploadType,
contentType,
fileName,
fileSize
);
logger.info("Presigned URL generated", {
userId: req.user.id,
uploadType,
key: result.key,
});
res.json(result);
} catch (error) {
if (error.message.includes("Invalid")) {
return res.status(400).json({ error: error.message });
}
next(error);
}
}
);
/**
* POST /api/upload/presign-batch
* Get presigned URLs for uploading multiple files to S3
*/
router.post(
"/presign-batch",
authenticateToken,
requireS3Enabled,
uploadPresignLimiter,
async (req, res, next) => {
try {
const { uploadType, files } = req.body;
if (!uploadType || !files || !Array.isArray(files)) {
return res.status(400).json({ error: "Missing required fields" });
}
if (files.length === 0) {
return res.status(400).json({ error: "No files specified" });
}
if (files.length > MAX_BATCH_SIZE) {
return res
.status(400)
.json({ error: "Maximum ${MAX_BATCH_SIZE} files per batch" });
}
// Validate each file has required fields
for (const file of files) {
if (!file.contentType || !file.fileName || !file.fileSize) {
return res.status(400).json({
error: "Each file must have contentType, fileName, and fileSize",
});
}
}
const results = await Promise.all(
files.map((f) =>
s3Service.getPresignedUploadUrl(
uploadType,
f.contentType,
f.fileName,
f.fileSize
)
)
);
logger.info("Batch presigned URLs generated", {
userId: req.user.id,
uploadType,
count: results.length,
});
res.json({ uploads: results });
} catch (error) {
if (error.message.includes("Invalid")) {
return res.status(400).json({ error: error.message });
}
next(error);
}
}
);
/**
* POST /api/upload/confirm
* Confirm that files have been uploaded to S3
*/
router.post(
"/confirm",
authenticateToken,
requireS3Enabled,
async (req, res, next) => {
try {
const { keys } = req.body;
if (!keys || !Array.isArray(keys)) {
return res.status(400).json({ error: "Missing keys array" });
}
if (keys.length === 0) {
return res.status(400).json({ error: "No keys specified" });
}
const results = await Promise.all(
keys.map(async (key) => ({
key,
exists: await s3Service.verifyUpload(key),
}))
);
const confirmed = results.filter((r) => r.exists).map((r) => r.key);
logger.info("Upload confirmation", {
userId: req.user.id,
confirmed: confirmed.length,
total: keys.length,
});
// Only return confirmed keys, not which ones failed (prevents file existence probing)
res.json({ confirmed, total: keys.length });
} catch (error) {
next(error);
}
}
);
/**
* GET /api/upload/signed-url/*key
* Get a signed URL for accessing private content (messages, condition-checks)
* The key is the full path after /signed-url/ (e.g., "messages/uuid.jpg")
*/
router.get(
"/signed-url/*key",
authenticateToken,
requireS3Enabled,
async (req, res, next) => {
try {
const { key } = req.params;
// Only allow private folders to use signed URLs
const isPrivate =
key.startsWith("messages/") || key.startsWith("condition-checks/");
if (!isPrivate) {
return res
.status(400)
.json({ error: "Signed URLs only for private content" });
}
// Verify user is authorized to access this file
const authResult = await S3OwnershipService.canAccessFile(
key,
req.user.id
);
if (!authResult.authorized) {
logger.warn("Unauthorized signed URL request", {
userId: req.user.id,
key,
reason: authResult.reason,
});
return res.status(403).json({ error: "Access denied" });
}
const url = await s3Service.getPresignedDownloadUrl(key);
res.json({ url, expiresIn: 3600 });
} catch (error) {
next(error);
}
}
);
module.exports = router;