s3
This commit is contained in:
@@ -104,6 +104,13 @@ const burstProtection = createUserBasedRateLimiter(
|
|||||||
"Too many requests in a short period. Please slow down."
|
"Too many requests in a short period. Please slow down."
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Upload presign rate limiter - 30 requests per minute
|
||||||
|
const uploadPresignLimiter = createUserBasedRateLimiter(
|
||||||
|
60 * 1000, // 1 minute window
|
||||||
|
30, // 30 presign requests per minute per user
|
||||||
|
"Too many upload requests. Please slow down."
|
||||||
|
);
|
||||||
|
|
||||||
// Authentication rate limiters
|
// Authentication rate limiters
|
||||||
const authRateLimiters = {
|
const authRateLimiters = {
|
||||||
// Login rate limiter - stricter to prevent brute force
|
// Login rate limiter - stricter to prevent brute force
|
||||||
@@ -184,6 +191,9 @@ module.exports = {
|
|||||||
// Burst protection
|
// Burst protection
|
||||||
burstProtection,
|
burstProtection,
|
||||||
|
|
||||||
|
// Upload rate limiter
|
||||||
|
uploadPresignLimiter,
|
||||||
|
|
||||||
// Utility functions
|
// Utility functions
|
||||||
createMapsRateLimiter,
|
createMapsRateLimiter,
|
||||||
createUserBasedRateLimiter,
|
createUserBasedRateLimiter,
|
||||||
|
|||||||
1724
backend/package-lock.json
generated
1724
backend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -34,8 +34,10 @@
|
|||||||
"author": "",
|
"author": "",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@aws-sdk/client-s3": "^3.940.0",
|
||||||
"@aws-sdk/client-ses": "^3.896.0",
|
"@aws-sdk/client-ses": "^3.896.0",
|
||||||
"@aws-sdk/credential-providers": "^3.901.0",
|
"@aws-sdk/credential-providers": "^3.901.0",
|
||||||
|
"@aws-sdk/s3-request-presigner": "^3.940.0",
|
||||||
"@googlemaps/google-maps-services-js": "^3.4.2",
|
"@googlemaps/google-maps-services-js": "^3.4.2",
|
||||||
"bcryptjs": "^3.0.2",
|
"bcryptjs": "^3.0.2",
|
||||||
"body-parser": "^2.2.0",
|
"body-parser": "^2.2.0",
|
||||||
|
|||||||
@@ -1,41 +1,21 @@
|
|||||||
const express = require("express");
|
const express = require("express");
|
||||||
const multer = require("multer");
|
|
||||||
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 router = express.Router();
|
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
|
// Submit a condition check
|
||||||
router.post(
|
router.post("/:rentalId", authenticateToken, async (req, res) => {
|
||||||
"/:rentalId",
|
|
||||||
authenticateToken,
|
|
||||||
upload.array("imageFilenames"),
|
|
||||||
async (req, res) => {
|
|
||||||
try {
|
try {
|
||||||
const { rentalId } = req.params;
|
const { rentalId } = req.params;
|
||||||
const { checkType, notes } = req.body;
|
const { checkType, notes, imageFilenames: rawImageFilenames } = req.body;
|
||||||
const userId = req.user.id;
|
const userId = req.user.id;
|
||||||
|
|
||||||
// Get uploaded file paths
|
// Ensure imageFilenames is an array (S3 keys)
|
||||||
const imageFilenames = req.files ? req.files.map((file) => file.path) : [];
|
const imageFilenames = Array.isArray(rawImageFilenames)
|
||||||
|
? rawImageFilenames
|
||||||
|
: [];
|
||||||
|
|
||||||
const conditionCheck = await ConditionCheckService.submitConditionCheck(
|
const conditionCheck = await ConditionCheckService.submitConditionCheck(
|
||||||
rentalId,
|
rentalId,
|
||||||
@@ -70,8 +50,7 @@ router.post(
|
|||||||
error: error.message,
|
error: error.message,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
);
|
|
||||||
|
|
||||||
// Get condition checks for a rental
|
// Get condition checks for a rental
|
||||||
router.get("/:rentalId", authenticateToken, async (req, res) => {
|
router.get("/:rentalId", authenticateToken, async (req, res) => {
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ const express = require('express');
|
|||||||
const { Op } = require('sequelize');
|
const { Op } = require('sequelize');
|
||||||
const { ForumPost, ForumComment, PostTag, User } = require('../models');
|
const { ForumPost, ForumComment, PostTag, User } = require('../models');
|
||||||
const { authenticateToken, requireAdmin, optionalAuth } = require('../middleware/auth');
|
const { authenticateToken, requireAdmin, optionalAuth } = require('../middleware/auth');
|
||||||
const { uploadForumPostImages, uploadForumCommentImages } = require('../middleware/upload');
|
|
||||||
const logger = require('../utils/logger');
|
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');
|
||||||
@@ -238,21 +237,12 @@ 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, uploadForumPostImages, async (req, res, next) => {
|
router.post('/posts', authenticateToken, async (req, res, next) => {
|
||||||
try {
|
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)
|
// Ensure imageFilenames is an array
|
||||||
if (typeof tags === 'string') {
|
imageFilenames = Array.isArray(imageFilenames) ? imageFilenames : [];
|
||||||
try {
|
|
||||||
tags = JSON.parse(tags);
|
|
||||||
} catch (e) {
|
|
||||||
tags = [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract image filenames if uploaded
|
|
||||||
const imageFilenames = req.files ? req.files.map(file => file.filename) : [];
|
|
||||||
|
|
||||||
// Initialize location fields
|
// Initialize location fields
|
||||||
let latitude = null;
|
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
|
// 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 {
|
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);
|
const post = await ForumPost.findByPk(req.params.id);
|
||||||
|
|
||||||
if (!post) {
|
if (!post) {
|
||||||
@@ -928,21 +920,21 @@ router.post('/posts/:id/comments', authenticateToken, uploadForumCommentImages,
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Validate parent comment if provided
|
// Validate parent comment if provided
|
||||||
if (parentCommentId) {
|
if (parentIdResolved) {
|
||||||
const parentComment = await ForumComment.findByPk(parentCommentId);
|
const parentComment = await ForumComment.findByPk(parentIdResolved);
|
||||||
if (!parentComment || parentComment.postId !== post.id) {
|
if (!parentComment || parentComment.postId !== post.id) {
|
||||||
return res.status(400).json({ error: 'Invalid parent comment' });
|
return res.status(400).json({ error: 'Invalid parent comment' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract image filenames if uploaded
|
// Ensure imageFilenames is an array
|
||||||
const imageFilenames = req.files ? req.files.map(file => file.filename) : [];
|
const imageFilenames = Array.isArray(rawImageFilenames) ? rawImageFilenames : [];
|
||||||
|
|
||||||
const comment = await ForumComment.create({
|
const comment = await ForumComment.create({
|
||||||
postId: req.params.id,
|
postId: req.params.id,
|
||||||
authorId: req.user.id,
|
authorId: req.user.id,
|
||||||
content,
|
content,
|
||||||
parentCommentId: parentCommentId || null,
|
parentCommentId: parentIdResolved || null,
|
||||||
imageFilenames
|
imageFilenames
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ const express = require('express');
|
|||||||
const helmet = require('helmet');
|
const helmet = require('helmet');
|
||||||
const { Message, User } = require('../models');
|
const { Message, User } = require('../models');
|
||||||
const { authenticateToken } = require('../middleware/auth');
|
const { authenticateToken } = require('../middleware/auth');
|
||||||
const { uploadMessageImage } = require('../middleware/upload');
|
|
||||||
const logger = require('../utils/logger');
|
const logger = require('../utils/logger');
|
||||||
const { emitNewMessage, emitMessageRead } = require('../sockets/messageSocket');
|
const { emitNewMessage, emitMessageRead } = require('../sockets/messageSocket');
|
||||||
const { Op } = require('sequelize');
|
const { Op } = require('sequelize');
|
||||||
@@ -237,9 +236,9 @@ router.get('/:id', authenticateToken, async (req, res, next) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Send a new message
|
// Send a new message
|
||||||
router.post('/', authenticateToken, uploadMessageImage, async (req, res, next) => {
|
router.post('/', authenticateToken, async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { receiverId, content } = req.body;
|
const { receiverId, content, imageFilename } = req.body;
|
||||||
|
|
||||||
// Check if receiver exists
|
// Check if receiver exists
|
||||||
const receiver = await User.findByPk(receiverId);
|
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' });
|
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({
|
const message = await Message.create({
|
||||||
senderId: req.user.id,
|
senderId: req.user.id,
|
||||||
receiverId,
|
receiverId,
|
||||||
content,
|
content,
|
||||||
imageFilename
|
imageFilename: imageFilename || null
|
||||||
});
|
});
|
||||||
|
|
||||||
const messageWithSender = await Message.findByPk(message.id, {
|
const messageWithSender = await Message.findByPk(message.id, {
|
||||||
|
|||||||
214
backend/routes/upload.js
Normal file
214
backend/routes/upload.js
Normal 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;
|
||||||
@@ -28,11 +28,13 @@ const stripeRoutes = require("./routes/stripe");
|
|||||||
const mapsRoutes = require("./routes/maps");
|
const mapsRoutes = require("./routes/maps");
|
||||||
const conditionCheckRoutes = require("./routes/conditionChecks");
|
const conditionCheckRoutes = require("./routes/conditionChecks");
|
||||||
const feedbackRoutes = require("./routes/feedback");
|
const feedbackRoutes = require("./routes/feedback");
|
||||||
|
const uploadRoutes = require("./routes/upload");
|
||||||
|
|
||||||
const PayoutProcessor = require("./jobs/payoutProcessor");
|
const PayoutProcessor = require("./jobs/payoutProcessor");
|
||||||
const RentalStatusJob = require("./jobs/rentalStatusJob");
|
const RentalStatusJob = require("./jobs/rentalStatusJob");
|
||||||
const ConditionCheckReminderJob = require("./jobs/conditionCheckReminder");
|
const ConditionCheckReminderJob = require("./jobs/conditionCheckReminder");
|
||||||
const emailServices = require("./services/email");
|
const emailServices = require("./services/email");
|
||||||
|
const s3Service = require("./services/s3Service");
|
||||||
|
|
||||||
// Socket.io setup
|
// Socket.io setup
|
||||||
const { authenticateSocket } = require("./sockets/socketAuth");
|
const { authenticateSocket } = require("./sockets/socketAuth");
|
||||||
@@ -159,6 +161,7 @@ app.use("/api/stripe", requireAlphaAccess, stripeRoutes);
|
|||||||
app.use("/api/maps", requireAlphaAccess, mapsRoutes);
|
app.use("/api/maps", requireAlphaAccess, mapsRoutes);
|
||||||
app.use("/api/condition-checks", requireAlphaAccess, conditionCheckRoutes);
|
app.use("/api/condition-checks", requireAlphaAccess, conditionCheckRoutes);
|
||||||
app.use("/api/feedback", requireAlphaAccess, feedbackRoutes);
|
app.use("/api/feedback", requireAlphaAccess, feedbackRoutes);
|
||||||
|
app.use("/api/upload", requireAlphaAccess, uploadRoutes);
|
||||||
|
|
||||||
// Error handling middleware (must be last)
|
// Error handling middleware (must be last)
|
||||||
app.use(errorLogger);
|
app.use(errorLogger);
|
||||||
@@ -195,13 +198,30 @@ sequelize
|
|||||||
});
|
});
|
||||||
// Fail fast - don't start server if email templates can't load
|
// Fail fast - don't start server if email templates can't load
|
||||||
if (env === "prod" || env === "production") {
|
if (env === "prod" || env === "production") {
|
||||||
logger.error("Cannot start server without email services in production");
|
logger.error(
|
||||||
|
"Cannot start server without email services in production"
|
||||||
|
);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
} else {
|
} else {
|
||||||
logger.warn("Email services failed to initialize - continuing in dev mode");
|
logger.warn(
|
||||||
|
"Email services failed to initialize - continuing in dev mode"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Initialize S3 service for image uploads
|
||||||
|
try {
|
||||||
|
s3Service.initialize();
|
||||||
|
logger.info("S3 service initialized successfully");
|
||||||
|
} catch (err) {
|
||||||
|
logger.error("Failed to initialize S3 service", {
|
||||||
|
error: err.message,
|
||||||
|
stack: err.stack,
|
||||||
|
});
|
||||||
|
logger.error("Cannot start server without S3 service in production");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
// Start the payout processor
|
// Start the payout processor
|
||||||
const payoutJobs = PayoutProcessor.startScheduledPayouts();
|
const payoutJobs = PayoutProcessor.startScheduledPayouts();
|
||||||
logger.info("Payout processor started");
|
logger.info("Payout processor started");
|
||||||
@@ -211,7 +231,8 @@ sequelize
|
|||||||
logger.info("Rental status job started");
|
logger.info("Rental status job started");
|
||||||
|
|
||||||
// Start the condition check reminder job
|
// Start the condition check reminder job
|
||||||
const conditionCheckJobs = ConditionCheckReminderJob.startScheduledReminders();
|
const conditionCheckJobs =
|
||||||
|
ConditionCheckReminderJob.startScheduledReminders();
|
||||||
logger.info("Condition check reminder job started");
|
logger.info("Condition check reminder job started");
|
||||||
|
|
||||||
server.listen(PORT, () => {
|
server.listen(PORT, () => {
|
||||||
|
|||||||
98
backend/services/s3OwnershipService.js
Normal file
98
backend/services/s3OwnershipService.js
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
const { Message, ConditionCheck, Rental } = require("../models");
|
||||||
|
const { Op } = require("sequelize");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service for verifying ownership/access to S3 files
|
||||||
|
* Used to authorize signed URL requests for private content
|
||||||
|
*/
|
||||||
|
class S3OwnershipService {
|
||||||
|
/**
|
||||||
|
* Extract file type from S3 key
|
||||||
|
* @param {string} key - S3 key like "messages/uuid.jpg"
|
||||||
|
* @returns {string|null} - File type or null if unknown
|
||||||
|
*/
|
||||||
|
static getFileTypeFromKey(key) {
|
||||||
|
if (!key) return null;
|
||||||
|
const folder = key.split("/")[0];
|
||||||
|
const folderMap = {
|
||||||
|
profiles: "profile",
|
||||||
|
items: "item",
|
||||||
|
messages: "message",
|
||||||
|
forum: "forum",
|
||||||
|
"condition-checks": "condition-check",
|
||||||
|
};
|
||||||
|
return folderMap[folder] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify if a user can access a file
|
||||||
|
* @param {string} key - S3 key
|
||||||
|
* @param {string} userId - User ID making the request
|
||||||
|
* @returns {Promise<{authorized: boolean, reason?: string}>}
|
||||||
|
*/
|
||||||
|
static async canAccessFile(key, userId) {
|
||||||
|
const fileType = this.getFileTypeFromKey(key);
|
||||||
|
|
||||||
|
switch (fileType) {
|
||||||
|
case "profile":
|
||||||
|
case "item":
|
||||||
|
case "forum":
|
||||||
|
// Public folders - anyone can access
|
||||||
|
return { authorized: true };
|
||||||
|
case "message":
|
||||||
|
return this.verifyMessageAccess(key, userId);
|
||||||
|
case "condition-check":
|
||||||
|
return this.verifyConditionCheckAccess(key, userId);
|
||||||
|
default:
|
||||||
|
return { authorized: false, reason: "Unknown file type" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify message image access - user must be sender OR receiver
|
||||||
|
* @param {string} key - S3 key
|
||||||
|
* @param {string} userId - User ID making the request
|
||||||
|
* @returns {Promise<{authorized: boolean, reason?: string}>}
|
||||||
|
*/
|
||||||
|
static async verifyMessageAccess(key, userId) {
|
||||||
|
const message = await Message.findOne({
|
||||||
|
where: {
|
||||||
|
imageFilename: key,
|
||||||
|
[Op.or]: [{ senderId: userId }, { receiverId: userId }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
authorized: !!message,
|
||||||
|
reason: message ? null : "Not a participant in this message",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify condition check image access - user must be rental owner OR renter
|
||||||
|
* @param {string} key - S3 key
|
||||||
|
* @param {string} userId - User ID making the request
|
||||||
|
* @returns {Promise<{authorized: boolean, reason?: string}>}
|
||||||
|
*/
|
||||||
|
static async verifyConditionCheckAccess(key, userId) {
|
||||||
|
const check = await ConditionCheck.findOne({
|
||||||
|
where: {
|
||||||
|
imageFilenames: { [Op.contains]: [key] },
|
||||||
|
},
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: Rental,
|
||||||
|
as: "rental",
|
||||||
|
where: {
|
||||||
|
[Op.or]: [{ ownerId: userId }, { renterId: userId }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
authorized: !!check,
|
||||||
|
reason: check ? null : "Not a participant in this rental",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = S3OwnershipService;
|
||||||
238
backend/services/s3Service.js
Normal file
238
backend/services/s3Service.js
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
const {
|
||||||
|
S3Client,
|
||||||
|
PutObjectCommand,
|
||||||
|
GetObjectCommand,
|
||||||
|
HeadObjectCommand,
|
||||||
|
} = require("@aws-sdk/client-s3");
|
||||||
|
const { getSignedUrl } = require("@aws-sdk/s3-request-presigner");
|
||||||
|
const { getAWSConfig } = require("../config/aws");
|
||||||
|
const { v4: uuidv4 } = require("uuid");
|
||||||
|
const path = require("path");
|
||||||
|
const logger = require("../utils/logger");
|
||||||
|
|
||||||
|
// Cache-Control: 24 hours for public content (allows moderation takedowns to propagate)
|
||||||
|
// Private content (messages, condition-checks) uses presigned URLs so cache doesn't matter as much
|
||||||
|
const DEFAULT_CACHE_MAX_AGE = 86400; // 24 hours in seconds
|
||||||
|
|
||||||
|
const UPLOAD_CONFIGS = {
|
||||||
|
profile: {
|
||||||
|
folder: "profiles",
|
||||||
|
maxSize: 5 * 1024 * 1024,
|
||||||
|
cacheMaxAge: DEFAULT_CACHE_MAX_AGE,
|
||||||
|
public: true,
|
||||||
|
},
|
||||||
|
item: {
|
||||||
|
folder: "items",
|
||||||
|
maxSize: 10 * 1024 * 1024,
|
||||||
|
cacheMaxAge: DEFAULT_CACHE_MAX_AGE,
|
||||||
|
public: true,
|
||||||
|
},
|
||||||
|
message: {
|
||||||
|
folder: "messages",
|
||||||
|
maxSize: 5 * 1024 * 1024,
|
||||||
|
cacheMaxAge: 3600,
|
||||||
|
public: false,
|
||||||
|
},
|
||||||
|
forum: {
|
||||||
|
folder: "forum",
|
||||||
|
maxSize: 10 * 1024 * 1024,
|
||||||
|
cacheMaxAge: DEFAULT_CACHE_MAX_AGE,
|
||||||
|
public: true,
|
||||||
|
},
|
||||||
|
"condition-check": {
|
||||||
|
folder: "condition-checks",
|
||||||
|
maxSize: 10 * 1024 * 1024,
|
||||||
|
cacheMaxAge: 3600,
|
||||||
|
public: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const ALLOWED_TYPES = [
|
||||||
|
"image/jpeg",
|
||||||
|
"image/jpg",
|
||||||
|
"image/png",
|
||||||
|
"image/gif",
|
||||||
|
"image/webp",
|
||||||
|
];
|
||||||
|
const PRESIGN_EXPIRY = 300; // 5 minutes
|
||||||
|
|
||||||
|
class S3Service {
|
||||||
|
constructor() {
|
||||||
|
this.client = null;
|
||||||
|
this.bucket = null;
|
||||||
|
this.region = null;
|
||||||
|
this.enabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if S3 is enabled
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
isEnabled() {
|
||||||
|
return this.enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
initialize() {
|
||||||
|
if (process.env.S3_ENABLED !== "true") {
|
||||||
|
logger.info("S3 Service disabled (S3_ENABLED !== true)");
|
||||||
|
this.enabled = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// S3 is enabled - validate required configuration
|
||||||
|
const bucket = process.env.S3_BUCKET;
|
||||||
|
if (!bucket) {
|
||||||
|
logger.error("S3_ENABLED=true but S3_BUCKET is not set");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const config = getAWSConfig();
|
||||||
|
this.client = new S3Client({
|
||||||
|
...config,
|
||||||
|
// Disable automatic checksums - browser uploads can't calculate them
|
||||||
|
requestChecksumCalculation: "WHEN_REQUIRED",
|
||||||
|
});
|
||||||
|
this.bucket = bucket;
|
||||||
|
this.region = config.region || "us-east-1";
|
||||||
|
this.enabled = true;
|
||||||
|
logger.info("S3 Service initialized", {
|
||||||
|
bucket: this.bucket,
|
||||||
|
region: this.region,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Failed to initialize S3 Service", { error: error.message });
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a presigned URL for uploading a file directly to S3
|
||||||
|
* @param {string} uploadType - Type of upload (profile, item, message, forum, condition-check)
|
||||||
|
* @param {string} contentType - MIME type of the file
|
||||||
|
* @param {string} fileName - Original filename (used for extension)
|
||||||
|
* @param {number} fileSize - File size in bytes (required for size enforcement)
|
||||||
|
* @returns {Promise<{uploadUrl: string, key: string, publicUrl: string, expiresAt: Date}>}
|
||||||
|
*/
|
||||||
|
async getPresignedUploadUrl(uploadType, contentType, fileName, fileSize) {
|
||||||
|
if (!this.enabled) {
|
||||||
|
throw new Error("S3 storage is not enabled");
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = UPLOAD_CONFIGS[uploadType];
|
||||||
|
if (!config) {
|
||||||
|
throw new Error(`Invalid upload type: ${uploadType}`);
|
||||||
|
}
|
||||||
|
if (!ALLOWED_TYPES.includes(contentType)) {
|
||||||
|
throw new Error(`Invalid content type: ${contentType}`);
|
||||||
|
}
|
||||||
|
if (!fileSize || fileSize <= 0) {
|
||||||
|
throw new Error("File size is required");
|
||||||
|
}
|
||||||
|
if (fileSize > config.maxSize) {
|
||||||
|
throw new Error(
|
||||||
|
`File too large. Maximum size is ${config.maxSize / (1024 * 1024)}MB`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ext = path.extname(fileName) || this.getExtFromMime(contentType);
|
||||||
|
const key = `${config.folder}/${uuidv4()}${ext}`;
|
||||||
|
|
||||||
|
const cacheDirective = config.public ? "public" : "private";
|
||||||
|
const command = new PutObjectCommand({
|
||||||
|
Bucket: this.bucket,
|
||||||
|
Key: key,
|
||||||
|
ContentType: contentType,
|
||||||
|
ContentLength: fileSize, // Enforce exact file size
|
||||||
|
CacheControl: `${cacheDirective}, max-age=${config.cacheMaxAge}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const uploadUrl = await getSignedUrl(this.client, command, {
|
||||||
|
expiresIn: PRESIGN_EXPIRY,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
uploadUrl,
|
||||||
|
key,
|
||||||
|
publicUrl: config.public
|
||||||
|
? `https://${this.bucket}.s3.${this.region}.amazonaws.com/${key}`
|
||||||
|
: null,
|
||||||
|
expiresAt: new Date(Date.now() + PRESIGN_EXPIRY * 1000),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a presigned URL for downloading a private file from S3
|
||||||
|
* @param {string} key - S3 object key
|
||||||
|
* @param {number} expiresIn - Expiration time in seconds (default 1 hour)
|
||||||
|
* @returns {Promise<string>}
|
||||||
|
*/
|
||||||
|
async getPresignedDownloadUrl(key, expiresIn = 3600) {
|
||||||
|
if (!this.enabled) {
|
||||||
|
throw new Error("S3 storage is not enabled");
|
||||||
|
}
|
||||||
|
|
||||||
|
const command = new GetObjectCommand({
|
||||||
|
Bucket: this.bucket,
|
||||||
|
Key: key,
|
||||||
|
});
|
||||||
|
return getSignedUrl(this.client, command, { expiresIn });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the public URL for a file (only for public folders)
|
||||||
|
* @param {string} key - S3 object key
|
||||||
|
* @returns {string|null}
|
||||||
|
*/
|
||||||
|
getPublicUrl(key) {
|
||||||
|
if (!this.enabled) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return `https://${this.bucket}.s3.${this.region}.amazonaws.com/${key}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify that a file exists in S3
|
||||||
|
* @param {string} key - S3 object key
|
||||||
|
* @returns {Promise<boolean>}
|
||||||
|
*/
|
||||||
|
async verifyUpload(key) {
|
||||||
|
if (!this.enabled) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.client.send(
|
||||||
|
new HeadObjectCommand({
|
||||||
|
Bucket: this.bucket,
|
||||||
|
Key: key,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
if (err.name === "NotFound" || err.$metadata?.httpStatusCode === 404) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get file extension from MIME type
|
||||||
|
* @param {string} mime - MIME type
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
getExtFromMime(mime) {
|
||||||
|
const map = {
|
||||||
|
"image/jpeg": ".jpg",
|
||||||
|
"image/jpg": ".jpg",
|
||||||
|
"image/png": ".png",
|
||||||
|
"image/gif": ".gif",
|
||||||
|
"image/webp": ".webp",
|
||||||
|
};
|
||||||
|
return map[mime] || ".jpg";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const s3Service = new S3Service();
|
||||||
|
module.exports = s3Service;
|
||||||
@@ -19,8 +19,6 @@ import {
|
|||||||
feedbackAPI,
|
feedbackAPI,
|
||||||
fetchCSRFToken,
|
fetchCSRFToken,
|
||||||
resetCSRFToken,
|
resetCSRFToken,
|
||||||
getMessageImageUrl,
|
|
||||||
getForumImageUrl,
|
|
||||||
} from '../../services/api';
|
} from '../../services/api';
|
||||||
import api from '../../services/api';
|
import api from '../../services/api';
|
||||||
|
|
||||||
@@ -91,22 +89,6 @@ describe('API Service', () => {
|
|||||||
expect(typeof resetCSRFToken).toBe('function');
|
expect(typeof resetCSRFToken).toBe('function');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('exports helper functions for image URLs', () => {
|
|
||||||
expect(typeof getMessageImageUrl).toBe('function');
|
|
||||||
expect(typeof getForumImageUrl).toBe('function');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Helper Functions', () => {
|
|
||||||
it('getMessageImageUrl constructs correct URL', () => {
|
|
||||||
const url = getMessageImageUrl('test-image.jpg');
|
|
||||||
expect(url).toContain('/messages/images/test-image.jpg');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('getForumImageUrl constructs correct URL', () => {
|
|
||||||
const url = getForumImageUrl('forum-image.jpg');
|
|
||||||
expect(url).toContain('/uploads/forum/forum-image.jpg');
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('CSRF Token Management', () => {
|
describe('CSRF Token Management', () => {
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import React, {
|
|||||||
useRef,
|
useRef,
|
||||||
useCallback,
|
useCallback,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { messageAPI, getMessageImageUrl } from "../services/api";
|
import { messageAPI } from "../services/api";
|
||||||
|
import { getSignedUrl } from "../services/uploadService";
|
||||||
import { User, Message } from "../types";
|
import { User, Message } from "../types";
|
||||||
import { useAuth } from "../contexts/AuthContext";
|
import { useAuth } from "../contexts/AuthContext";
|
||||||
import { useSocket } from "../contexts/SocketContext";
|
import { useSocket } from "../contexts/SocketContext";
|
||||||
@@ -46,6 +47,7 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
|||||||
const [hasScrolledToUnread, setHasScrolledToUnread] = useState(false);
|
const [hasScrolledToUnread, setHasScrolledToUnread] = useState(false);
|
||||||
const [selectedImage, setSelectedImage] = useState<File | null>(null);
|
const [selectedImage, setSelectedImage] = useState<File | null>(null);
|
||||||
const [imagePreview, setImagePreview] = useState<string | null>(null);
|
const [imagePreview, setImagePreview] = useState<string | null>(null);
|
||||||
|
const [imageUrls, setImageUrls] = useState<Map<string, string>>(new Map());
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
const messagesContainerRef = useRef<HTMLDivElement>(null);
|
const messagesContainerRef = useRef<HTMLDivElement>(null);
|
||||||
const messageRefs = useRef<Map<string, HTMLDivElement>>(new Map());
|
const messageRefs = useRef<Map<string, HTMLDivElement>>(new Map());
|
||||||
@@ -189,6 +191,29 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
|||||||
}
|
}
|
||||||
}, [messages, isRecipientTyping, isAtBottom, hasScrolledToUnread]);
|
}, [messages, isRecipientTyping, isAtBottom, hasScrolledToUnread]);
|
||||||
|
|
||||||
|
// Pre-fetch signed URLs for private message images
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchImageUrls = async () => {
|
||||||
|
const messagesWithImages = messages.filter(
|
||||||
|
(m) => m.imageFilename && !imageUrls.has(m.imageFilename)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (messagesWithImages.length === 0) return;
|
||||||
|
|
||||||
|
const newUrls = new Map(imageUrls);
|
||||||
|
await Promise.all(
|
||||||
|
messagesWithImages.map(async (m) => {
|
||||||
|
const url = await getSignedUrl(m.imageFilename!);
|
||||||
|
newUrls.set(m.imageFilename!, url);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
setImageUrls(newUrls);
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchImageUrls();
|
||||||
|
}, [messages]);
|
||||||
|
|
||||||
const fetchMessages = async () => {
|
const fetchMessages = async () => {
|
||||||
try {
|
try {
|
||||||
// Fetch all messages between current user and recipient
|
// Fetch all messages between current user and recipient
|
||||||
@@ -525,10 +550,11 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
|||||||
wordBreak: "break-word",
|
wordBreak: "break-word",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{message.imageFilename && (
|
{message.imageFilename &&
|
||||||
|
imageUrls.has(message.imageFilename) && (
|
||||||
<div className="mb-2">
|
<div className="mb-2">
|
||||||
<img
|
<img
|
||||||
src={getMessageImageUrl(message.imageFilename)}
|
src={imageUrls.get(message.imageFilename)}
|
||||||
alt="Shared image"
|
alt="Shared image"
|
||||||
style={{
|
style={{
|
||||||
width: "100%",
|
width: "100%",
|
||||||
@@ -539,7 +565,7 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
|||||||
}}
|
}}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
window.open(
|
window.open(
|
||||||
getMessageImageUrl(message.imageFilename!),
|
imageUrls.get(message.imageFilename!),
|
||||||
"_blank"
|
"_blank"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { ForumComment } from "../types";
|
import { ForumComment } from "../types";
|
||||||
import CommentForm from "./CommentForm";
|
import CommentForm from "./CommentForm";
|
||||||
import { getForumImageUrl } from "../services/api";
|
import { getPublicImageUrl } from "../services/uploadService";
|
||||||
|
|
||||||
interface CommentThreadProps {
|
interface CommentThreadProps {
|
||||||
comment: ForumComment;
|
comment: ForumComment;
|
||||||
@@ -217,7 +217,7 @@ const CommentThread: React.FC<CommentThreadProps> = ({
|
|||||||
{comment.imageFilenames.map((image, index) => (
|
{comment.imageFilenames.map((image, index) => (
|
||||||
<div key={index} className="col-4 col-md-3">
|
<div key={index} className="col-4 col-md-3">
|
||||||
<img
|
<img
|
||||||
src={getForumImageUrl(image)}
|
src={getPublicImageUrl(image)}
|
||||||
alt={`Comment image`}
|
alt={`Comment image`}
|
||||||
className="img-fluid rounded"
|
className="img-fluid rounded"
|
||||||
style={{
|
style={{
|
||||||
@@ -227,7 +227,7 @@ const CommentThread: React.FC<CommentThreadProps> = ({
|
|||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
}}
|
}}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
window.open(getForumImageUrl(image), "_blank")
|
window.open(getPublicImageUrl(image), "_blank")
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { Item } from '../types';
|
import { Item } from '../types';
|
||||||
|
import { getPublicImageUrl } from '../services/uploadService';
|
||||||
|
|
||||||
interface ItemCardProps {
|
interface ItemCardProps {
|
||||||
item: Item;
|
item: Item;
|
||||||
@@ -49,12 +50,13 @@ const ItemCard: React.FC<ItemCardProps> = ({
|
|||||||
<div className="card h-100" style={{ cursor: 'pointer' }}>
|
<div className="card h-100" style={{ cursor: 'pointer' }}>
|
||||||
{item.imageFilenames && item.imageFilenames[0] ? (
|
{item.imageFilenames && item.imageFilenames[0] ? (
|
||||||
<img
|
<img
|
||||||
src={item.imageFilenames[0]}
|
src={getPublicImageUrl(item.imageFilenames[0])}
|
||||||
className="card-img-top"
|
className="card-img-top"
|
||||||
alt={item.name}
|
alt={item.name}
|
||||||
style={{
|
style={{
|
||||||
height: isCompact ? '150px' : '200px',
|
height: isCompact ? '150px' : '200px',
|
||||||
objectFit: 'cover'
|
objectFit: 'contain',
|
||||||
|
backgroundColor: '#f8f9fa'
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Item } from '../types';
|
import { Item } from '../types';
|
||||||
|
import { getPublicImageUrl } from '../services/uploadService';
|
||||||
|
|
||||||
interface ItemMarkerInfoProps {
|
interface ItemMarkerInfoProps {
|
||||||
item: Item;
|
item: Item;
|
||||||
@@ -31,12 +32,13 @@ const ItemMarkerInfo: React.FC<ItemMarkerInfoProps> = ({ item, onViewDetails })
|
|||||||
<div className="card border-0">
|
<div className="card border-0">
|
||||||
{item.imageFilenames && item.imageFilenames[0] ? (
|
{item.imageFilenames && item.imageFilenames[0] ? (
|
||||||
<img
|
<img
|
||||||
src={item.imageFilenames[0]}
|
src={getPublicImageUrl(item.imageFilenames[0])}
|
||||||
className="card-img-top"
|
className="card-img-top"
|
||||||
alt={item.name}
|
alt={item.name}
|
||||||
style={{
|
style={{
|
||||||
height: '120px',
|
height: '120px',
|
||||||
objectFit: 'cover',
|
objectFit: 'contain',
|
||||||
|
backgroundColor: '#f8f9fa',
|
||||||
borderRadius: '8px 8px 0 0'
|
borderRadius: '8px 8px 0 0'
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React, { useState, useEffect } from "react";
|
|||||||
import { useNavigate, Link } from "react-router-dom";
|
import { useNavigate, Link } from "react-router-dom";
|
||||||
import { useAuth } from "../contexts/AuthContext";
|
import { useAuth } from "../contexts/AuthContext";
|
||||||
import { forumAPI, addressAPI } from "../services/api";
|
import { forumAPI, addressAPI } from "../services/api";
|
||||||
|
import { uploadFiles } from "../services/uploadService";
|
||||||
import TagInput from "../components/TagInput";
|
import TagInput from "../components/TagInput";
|
||||||
import ForumImageUpload from "../components/ForumImageUpload";
|
import ForumImageUpload from "../components/ForumImageUpload";
|
||||||
import { Address } from "../types";
|
import { Address } from "../types";
|
||||||
@@ -151,36 +152,53 @@ const CreateForumPost: React.FC = () => {
|
|||||||
try {
|
try {
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
|
|
||||||
// Create FormData
|
// Upload images to S3 first (if any)
|
||||||
const submitData = new FormData();
|
let imageFilenames: string[] = [];
|
||||||
submitData.append('title', formData.title);
|
if (imageFiles.length > 0) {
|
||||||
submitData.append('content', formData.content);
|
const uploadResults = await uploadFiles("forum", imageFiles);
|
||||||
submitData.append('category', formData.category);
|
imageFilenames = uploadResults.map((result) => result.key);
|
||||||
|
}
|
||||||
|
|
||||||
// Add tags as JSON string
|
// Build the post data
|
||||||
|
const postData: {
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
category: string;
|
||||||
|
tags?: string[];
|
||||||
|
zipCode?: string;
|
||||||
|
latitude?: number;
|
||||||
|
longitude?: number;
|
||||||
|
imageFilenames?: string[];
|
||||||
|
} = {
|
||||||
|
title: formData.title,
|
||||||
|
content: formData.content,
|
||||||
|
category: formData.category,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add tags if present
|
||||||
if (formData.tags.length > 0) {
|
if (formData.tags.length > 0) {
|
||||||
submitData.append('tags', JSON.stringify(formData.tags));
|
postData.tags = formData.tags;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add location data for item requests
|
// Add location data for item requests
|
||||||
if (formData.category === 'item_request' && formData.zipCode) {
|
if (formData.category === 'item_request' && formData.zipCode) {
|
||||||
submitData.append('zipCode', formData.zipCode);
|
postData.zipCode = formData.zipCode;
|
||||||
// If we have coordinates from a saved address, send them to avoid re-geocoding
|
// If we have coordinates from a saved address, send them to avoid re-geocoding
|
||||||
if (formData.latitude !== undefined && formData.longitude !== undefined) {
|
if (formData.latitude !== undefined && formData.longitude !== undefined) {
|
||||||
submitData.append('latitude', formData.latitude.toString());
|
postData.latitude = formData.latitude;
|
||||||
submitData.append('longitude', formData.longitude.toString());
|
postData.longitude = formData.longitude;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add images
|
// Add S3 image keys
|
||||||
imageFiles.forEach((file) => {
|
if (imageFilenames.length > 0) {
|
||||||
submitData.append('images', file);
|
postData.imageFilenames = imageFilenames;
|
||||||
});
|
}
|
||||||
|
|
||||||
const response = await forumAPI.createPost(submitData);
|
const response = await forumAPI.createPost(postData);
|
||||||
navigate(`/forum/${response.data.id}`);
|
navigate(`/forum/${response.data.id}`);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.response?.data?.error || "Failed to create post");
|
setError(err.response?.data?.error || err.message || "Failed to create post");
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React, { useState, useEffect, useRef } from "react";
|
|||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useAuth } from "../contexts/AuthContext";
|
import { useAuth } from "../contexts/AuthContext";
|
||||||
import api, { addressAPI, userAPI, itemAPI } from "../services/api";
|
import api, { addressAPI, userAPI, itemAPI } from "../services/api";
|
||||||
|
import { uploadFiles } from "../services/uploadService";
|
||||||
import AvailabilitySettings from "../components/AvailabilitySettings";
|
import AvailabilitySettings from "../components/AvailabilitySettings";
|
||||||
import ImageUpload from "../components/ImageUpload";
|
import ImageUpload from "../components/ImageUpload";
|
||||||
import ItemInformation from "../components/ItemInformation";
|
import ItemInformation from "../components/ItemInformation";
|
||||||
@@ -175,9 +176,12 @@ const CreateItem: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// For now, we'll store image URLs as base64 strings
|
// Upload images to S3 first
|
||||||
// In production, you'd upload to a service like S3
|
let imageFilenames: string[] = [];
|
||||||
const imageUrls = imagePreviews;
|
if (imageFiles.length > 0) {
|
||||||
|
const uploadResults = await uploadFiles("item", imageFiles);
|
||||||
|
imageFilenames = uploadResults.map((result) => result.key);
|
||||||
|
}
|
||||||
|
|
||||||
// Construct location from address components
|
// Construct location from address components
|
||||||
const locationParts = [
|
const locationParts = [
|
||||||
@@ -216,7 +220,7 @@ const CreateItem: React.FC = () => {
|
|||||||
specifyTimesPerDay: formData.specifyTimesPerDay,
|
specifyTimesPerDay: formData.specifyTimesPerDay,
|
||||||
weeklyTimes: formData.weeklyTimes,
|
weeklyTimes: formData.weeklyTimes,
|
||||||
location,
|
location,
|
||||||
images: imageUrls,
|
imageFilenames,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Auto-save address if user has no addresses and entered manual address
|
// Auto-save address if user has no addresses and entered manual address
|
||||||
@@ -260,7 +264,7 @@ const CreateItem: React.FC = () => {
|
|||||||
|
|
||||||
navigate(`/items/${response.data.id}`);
|
navigate(`/items/${response.data.id}`);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.response?.data?.error || "Failed to create listing");
|
setError(err.response?.data?.error || err.message || "Failed to create listing");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useParams, useNavigate } from "react-router-dom";
|
|||||||
import { Item, Rental, Address } from "../types";
|
import { Item, Rental, Address } from "../types";
|
||||||
import { useAuth } from "../contexts/AuthContext";
|
import { useAuth } from "../contexts/AuthContext";
|
||||||
import { itemAPI, rentalAPI, addressAPI, userAPI } from "../services/api";
|
import { itemAPI, rentalAPI, addressAPI, userAPI } from "../services/api";
|
||||||
|
import { uploadFiles, getPublicImageUrl } from "../services/uploadService";
|
||||||
import AvailabilitySettings from "../components/AvailabilitySettings";
|
import AvailabilitySettings from "../components/AvailabilitySettings";
|
||||||
import ImageUpload from "../components/ImageUpload";
|
import ImageUpload from "../components/ImageUpload";
|
||||||
import ItemInformation from "../components/ItemInformation";
|
import ItemInformation from "../components/ItemInformation";
|
||||||
@@ -53,6 +54,7 @@ const EditItem: React.FC = () => {
|
|||||||
const [success, setSuccess] = useState(false);
|
const [success, setSuccess] = useState(false);
|
||||||
const [imageFiles, setImageFiles] = useState<File[]>([]);
|
const [imageFiles, setImageFiles] = useState<File[]>([]);
|
||||||
const [imagePreviews, setImagePreviews] = useState<string[]>([]);
|
const [imagePreviews, setImagePreviews] = useState<string[]>([]);
|
||||||
|
const [existingImageKeys, setExistingImageKeys] = useState<string[]>([]); // S3 keys for existing images
|
||||||
const [acceptedRentals, setAcceptedRentals] = useState<Rental[]>([]);
|
const [acceptedRentals, setAcceptedRentals] = useState<Rental[]>([]);
|
||||||
const [userAddresses, setUserAddresses] = useState<Address[]>([]);
|
const [userAddresses, setUserAddresses] = useState<Address[]>([]);
|
||||||
const [selectedAddressId, setSelectedAddressId] = useState<string>("");
|
const [selectedAddressId, setSelectedAddressId] = useState<string>("");
|
||||||
@@ -161,9 +163,11 @@ const EditItem: React.FC = () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Set existing images as previews
|
// Set existing images - store S3 keys and generate preview URLs
|
||||||
if (item.imageFilenames && item.imageFilenames.length > 0) {
|
if (item.imageFilenames && item.imageFilenames.length > 0) {
|
||||||
setImagePreviews(item.imageFilenames);
|
setExistingImageKeys(item.imageFilenames);
|
||||||
|
// Generate preview URLs from S3 keys
|
||||||
|
setImagePreviews(item.imageFilenames.map((key: string) => getPublicImageUrl(key)));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine which pricing unit to select based on existing data
|
// Determine which pricing unit to select based on existing data
|
||||||
@@ -270,8 +274,15 @@ const EditItem: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Use existing image previews (which includes both old and new images)
|
// Upload new images to S3 and get their keys
|
||||||
const imageUrls = imagePreviews;
|
let newImageKeys: string[] = [];
|
||||||
|
if (imageFiles.length > 0) {
|
||||||
|
const uploadResults = await uploadFiles("item", imageFiles);
|
||||||
|
newImageKeys = uploadResults.map((result) => result.key);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Combine existing S3 keys with newly uploaded keys
|
||||||
|
const allImageKeys = [...existingImageKeys, ...newImageKeys];
|
||||||
|
|
||||||
const updatePayload = {
|
const updatePayload = {
|
||||||
...formData,
|
...formData,
|
||||||
@@ -297,7 +308,7 @@ const EditItem: React.FC = () => {
|
|||||||
availableBefore: formData.generalAvailableBefore,
|
availableBefore: formData.generalAvailableBefore,
|
||||||
specifyTimesPerDay: formData.specifyTimesPerDay,
|
specifyTimesPerDay: formData.specifyTimesPerDay,
|
||||||
weeklyTimes: formData.weeklyTimes,
|
weeklyTimes: formData.weeklyTimes,
|
||||||
images: imageUrls,
|
imageFilenames: allImageKeys,
|
||||||
};
|
};
|
||||||
|
|
||||||
await itemAPI.updateItem(id!, updatePayload);
|
await itemAPI.updateItem(id!, updatePayload);
|
||||||
@@ -328,7 +339,7 @@ const EditItem: React.FC = () => {
|
|||||||
navigate(`/items/${id}`);
|
navigate(`/items/${id}`);
|
||||||
}, 1500);
|
}, 1500);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.response?.data?.message || "Failed to update item");
|
setError(err.response?.data?.message || err.message || "Failed to update item");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -355,6 +366,16 @@ const EditItem: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const removeImage = (index: number) => {
|
const removeImage = (index: number) => {
|
||||||
|
// Check if removing an existing image or a new upload
|
||||||
|
if (index < existingImageKeys.length) {
|
||||||
|
// Removing an existing S3 image
|
||||||
|
setExistingImageKeys((prev) => prev.filter((_, i) => i !== index));
|
||||||
|
} else {
|
||||||
|
// Removing a new upload - adjust index for the imageFiles array
|
||||||
|
const newFileIndex = index - existingImageKeys.length;
|
||||||
|
setImageFiles((prev) => prev.filter((_, i) => i !== newFileIndex));
|
||||||
|
}
|
||||||
|
// Always update previews
|
||||||
setImagePreviews((prev) => prev.filter((_, i) => i !== index));
|
setImagePreviews((prev) => prev.filter((_, i) => i !== index));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { useParams, useNavigate, Link, useSearchParams } from 'react-router-dom';
|
import { useParams, useNavigate, Link, useSearchParams } from 'react-router-dom';
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
import { forumAPI, getForumImageUrl } from '../services/api';
|
import { forumAPI } from '../services/api';
|
||||||
|
import { uploadFiles, getPublicImageUrl } from '../services/uploadService';
|
||||||
import { ForumPost, ForumComment } from '../types';
|
import { ForumPost, ForumComment } from '../types';
|
||||||
import CategoryBadge from '../components/CategoryBadge';
|
import CategoryBadge from '../components/CategoryBadge';
|
||||||
import PostStatusBadge from '../components/PostStatusBadge';
|
import PostStatusBadge from '../components/PostStatusBadge';
|
||||||
@@ -54,17 +55,20 @@ const ForumPostDetail: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const formData = new FormData();
|
// Upload images to S3 first (if any)
|
||||||
formData.append('content', content);
|
let imageFilenames: string[] = [];
|
||||||
|
if (images.length > 0) {
|
||||||
|
const uploadResults = await uploadFiles("forum", images);
|
||||||
|
imageFilenames = uploadResults.map((result) => result.key);
|
||||||
|
}
|
||||||
|
|
||||||
images.forEach((file) => {
|
await forumAPI.createComment(id!, {
|
||||||
formData.append('images', file);
|
content,
|
||||||
|
imageFilenames: imageFilenames.length > 0 ? imageFilenames : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
await forumAPI.createComment(id!, formData);
|
|
||||||
await fetchPost(); // Refresh to get new comment
|
await fetchPost(); // Refresh to get new comment
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
throw new Error(err.response?.data?.error || 'Failed to post comment');
|
throw new Error(err.response?.data?.error || err.message || 'Failed to post comment');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -75,14 +79,13 @@ const ForumPostDetail: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const formData = new FormData();
|
await forumAPI.createComment(id!, {
|
||||||
formData.append('content', content);
|
content,
|
||||||
formData.append('parentCommentId', parentCommentId);
|
parentId: parentCommentId,
|
||||||
|
});
|
||||||
await forumAPI.createComment(id!, formData);
|
|
||||||
await fetchPost(); // Refresh to get new reply
|
await fetchPost(); // Refresh to get new reply
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
throw new Error(err.response?.data?.error || 'Failed to post reply');
|
throw new Error(err.response?.data?.error || err.message || 'Failed to post reply');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -348,11 +351,11 @@ const ForumPostDetail: React.FC = () => {
|
|||||||
{post.imageFilenames.map((image, index) => (
|
{post.imageFilenames.map((image, index) => (
|
||||||
<div key={index} className="col-6 col-md-4">
|
<div key={index} className="col-6 col-md-4">
|
||||||
<img
|
<img
|
||||||
src={getForumImageUrl(image)}
|
src={getPublicImageUrl(image)}
|
||||||
alt={`Post image`}
|
alt={`Post image`}
|
||||||
className="img-fluid rounded"
|
className="img-fluid rounded"
|
||||||
style={{ width: '100%', height: '200px', objectFit: 'cover', cursor: 'pointer' }}
|
style={{ width: '100%', height: '200px', objectFit: 'cover', cursor: 'pointer' }}
|
||||||
onClick={() => window.open(getForumImageUrl(image), '_blank')}
|
onClick={() => window.open(getPublicImageUrl(image), '_blank')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useParams, useNavigate } from "react-router-dom";
|
|||||||
import { Item, Rental } from "../types";
|
import { Item, Rental } from "../types";
|
||||||
import { useAuth } from "../contexts/AuthContext";
|
import { useAuth } from "../contexts/AuthContext";
|
||||||
import { itemAPI, rentalAPI } from "../services/api";
|
import { itemAPI, rentalAPI } from "../services/api";
|
||||||
|
import { getPublicImageUrl } from "../services/uploadService";
|
||||||
import GoogleMapWithRadius from "../components/GoogleMapWithRadius";
|
import GoogleMapWithRadius from "../components/GoogleMapWithRadius";
|
||||||
import ItemReviews from "../components/ItemReviews";
|
import ItemReviews from "../components/ItemReviews";
|
||||||
import ConfirmationModal from "../components/ConfirmationModal";
|
import ConfirmationModal from "../components/ConfirmationModal";
|
||||||
@@ -417,13 +418,14 @@ const ItemDetail: React.FC = () => {
|
|||||||
{item.imageFilenames.length > 0 ? (
|
{item.imageFilenames.length > 0 ? (
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<img
|
<img
|
||||||
src={item.imageFilenames[selectedImage]}
|
src={getPublicImageUrl(item.imageFilenames[selectedImage])}
|
||||||
alt={item.name}
|
alt={item.name}
|
||||||
className="img-fluid rounded mb-3"
|
className="img-fluid rounded mb-3"
|
||||||
style={{
|
style={{
|
||||||
width: "100%",
|
width: "100%",
|
||||||
maxHeight: "500px",
|
maxHeight: "500px",
|
||||||
objectFit: "cover",
|
objectFit: "contain",
|
||||||
|
backgroundColor: "#f8f9fa",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{item.imageFilenames.length > 1 && (
|
{item.imageFilenames.length > 1 && (
|
||||||
@@ -431,7 +433,7 @@ const ItemDetail: React.FC = () => {
|
|||||||
{item.imageFilenames.map((image, index) => (
|
{item.imageFilenames.map((image, index) => (
|
||||||
<img
|
<img
|
||||||
key={index}
|
key={index}
|
||||||
src={image}
|
src={getPublicImageUrl(image)}
|
||||||
alt={`${item.name} ${index + 1}`}
|
alt={`${item.name} ${index + 1}`}
|
||||||
className={`rounded cursor-pointer ${
|
className={`rounded cursor-pointer ${
|
||||||
selectedImage === index
|
selectedImage === index
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useAuth } from "../contexts/AuthContext";
|
|||||||
import api from "../services/api";
|
import api from "../services/api";
|
||||||
import { Item, Rental } from "../types";
|
import { Item, Rental } from "../types";
|
||||||
import { rentalAPI, conditionCheckAPI } from "../services/api";
|
import { rentalAPI, conditionCheckAPI } from "../services/api";
|
||||||
|
import { getPublicImageUrl } from "../services/uploadService";
|
||||||
import ReviewRenterModal from "../components/ReviewRenterModal";
|
import ReviewRenterModal from "../components/ReviewRenterModal";
|
||||||
import RentalCancellationModal from "../components/RentalCancellationModal";
|
import RentalCancellationModal from "../components/RentalCancellationModal";
|
||||||
import DeclineRentalModal from "../components/DeclineRentalModal";
|
import DeclineRentalModal from "../components/DeclineRentalModal";
|
||||||
@@ -308,10 +309,10 @@ const Owning: React.FC = () => {
|
|||||||
<div className="card h-100">
|
<div className="card h-100">
|
||||||
{rental.item?.imageFilenames && rental.item.imageFilenames[0] && (
|
{rental.item?.imageFilenames && rental.item.imageFilenames[0] && (
|
||||||
<img
|
<img
|
||||||
src={rental.item.imageFilenames[0]}
|
src={getPublicImageUrl(rental.item.imageFilenames[0])}
|
||||||
className="card-img-top"
|
className="card-img-top"
|
||||||
alt={rental.item.name}
|
alt={rental.item.name}
|
||||||
style={{ height: "200px", objectFit: "cover" }}
|
style={{ height: "200px", objectFit: "contain", backgroundColor: "#f8f9fa" }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div className="card-body">
|
<div className="card-body">
|
||||||
@@ -529,10 +530,10 @@ const Owning: React.FC = () => {
|
|||||||
>
|
>
|
||||||
{item.imageFilenames && item.imageFilenames[0] && (
|
{item.imageFilenames && item.imageFilenames[0] && (
|
||||||
<img
|
<img
|
||||||
src={item.imageFilenames[0]}
|
src={getPublicImageUrl(item.imageFilenames[0])}
|
||||||
className="card-img-top"
|
className="card-img-top"
|
||||||
alt={item.name}
|
alt={item.name}
|
||||||
style={{ height: "200px", objectFit: "cover" }}
|
style={{ height: "200px", objectFit: "contain", backgroundColor: "#f8f9fa" }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div className="card-body">
|
<div className="card-body">
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useNavigate } from "react-router-dom";
|
|||||||
import { useAuth } from "../contexts/AuthContext";
|
import { useAuth } from "../contexts/AuthContext";
|
||||||
import { userAPI, itemAPI, rentalAPI, addressAPI } from "../services/api";
|
import { userAPI, itemAPI, rentalAPI, addressAPI } from "../services/api";
|
||||||
import { User, Item, Rental, Address } from "../types";
|
import { User, Item, Rental, Address } from "../types";
|
||||||
import { getImageUrl } from "../utils/imageUrl";
|
import { uploadFile, getPublicImageUrl } from "../services/uploadService";
|
||||||
import AvailabilitySettings from "../components/AvailabilitySettings";
|
import AvailabilitySettings from "../components/AvailabilitySettings";
|
||||||
import ReviewItemModal from "../components/ReviewModal";
|
import ReviewItemModal from "../components/ReviewModal";
|
||||||
import ReviewRenterModal from "../components/ReviewRenterModal";
|
import ReviewRenterModal from "../components/ReviewRenterModal";
|
||||||
@@ -161,7 +161,7 @@ const Profile: React.FC = () => {
|
|||||||
response.data.itemRequestNotificationRadius || 10,
|
response.data.itemRequestNotificationRadius || 10,
|
||||||
});
|
});
|
||||||
if (response.data.imageFilename) {
|
if (response.data.imageFilename) {
|
||||||
setImagePreview(getImageUrl(response.data.imageFilename));
|
setImagePreview(getPublicImageUrl(response.data.imageFilename));
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.response?.data?.message || "Failed to fetch profile");
|
setError(err.response?.data?.message || "Failed to fetch profile");
|
||||||
@@ -301,29 +301,26 @@ const Profile: React.FC = () => {
|
|||||||
};
|
};
|
||||||
reader.readAsDataURL(file);
|
reader.readAsDataURL(file);
|
||||||
|
|
||||||
// Upload image immediately
|
// Upload image to S3
|
||||||
try {
|
try {
|
||||||
const formData = new FormData();
|
const { key, publicUrl } = await uploadFile("profile", file);
|
||||||
formData.append("imageFilename", file);
|
|
||||||
|
|
||||||
const response = await userAPI.uploadProfileImage(formData);
|
// Update the imageFilename in formData with the S3 key
|
||||||
|
|
||||||
// Update the imageFilename in formData with the new filename
|
|
||||||
setFormData((prev) => ({
|
setFormData((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
imageFilename: response.data.filename,
|
imageFilename: key,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Update preview to use the uploaded image URL
|
// Update preview to use the S3 URL
|
||||||
setImagePreview(getImageUrl(response.data.imageUrl));
|
setImagePreview(publicUrl);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error("Image upload error:", err);
|
console.error("Image upload error:", err);
|
||||||
setError(err.response?.data?.error || "Failed to upload image");
|
setError(err.message || "Failed to upload image");
|
||||||
// Reset on error
|
// Reset on error
|
||||||
setImageFile(null);
|
setImageFile(null);
|
||||||
setImagePreview(
|
setImagePreview(
|
||||||
profileData?.imageFilename
|
profileData?.imageFilename
|
||||||
? getImageUrl(profileData.imageFilename)
|
? getPublicImageUrl(profileData.imageFilename)
|
||||||
: null
|
: null
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -384,7 +381,7 @@ const Profile: React.FC = () => {
|
|||||||
profileData.itemRequestNotificationRadius || 10,
|
profileData.itemRequestNotificationRadius || 10,
|
||||||
});
|
});
|
||||||
setImagePreview(
|
setImagePreview(
|
||||||
profileData.imageFilename ? getImageUrl(profileData.imageFilename) : null
|
profileData.imageFilename ? getPublicImageUrl(profileData.imageFilename) : null
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -1224,7 +1221,7 @@ const Profile: React.FC = () => {
|
|||||||
<div className="card h-100">
|
<div className="card h-100">
|
||||||
{rental.item?.imageFilenames && rental.item.imageFilenames[0] && (
|
{rental.item?.imageFilenames && rental.item.imageFilenames[0] && (
|
||||||
<img
|
<img
|
||||||
src={rental.item.imageFilenames[0]}
|
src={getPublicImageUrl(rental.item.imageFilenames[0])}
|
||||||
className="card-img-top"
|
className="card-img-top"
|
||||||
alt={rental.item.name}
|
alt={rental.item.name}
|
||||||
style={{
|
style={{
|
||||||
@@ -1361,7 +1358,7 @@ const Profile: React.FC = () => {
|
|||||||
<div className="card h-100">
|
<div className="card h-100">
|
||||||
{rental.item?.imageFilenames && rental.item.imageFilenames[0] && (
|
{rental.item?.imageFilenames && rental.item.imageFilenames[0] && (
|
||||||
<img
|
<img
|
||||||
src={rental.item.imageFilenames[0]}
|
src={getPublicImageUrl(rental.item.imageFilenames[0])}
|
||||||
className="card-img-top"
|
className="card-img-top"
|
||||||
alt={rental.item.name}
|
alt={rental.item.name}
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react';
|
|||||||
import { useParams, useNavigate } from 'react-router-dom';
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
import { User, Item } from '../types';
|
import { User, Item } from '../types';
|
||||||
import { userAPI, itemAPI } from '../services/api';
|
import { userAPI, itemAPI } from '../services/api';
|
||||||
|
import { getPublicImageUrl } from '../services/uploadService';
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
import ChatWindow from '../components/ChatWindow';
|
import ChatWindow from '../components/ChatWindow';
|
||||||
|
|
||||||
@@ -113,10 +114,10 @@ const PublicProfile: React.FC = () => {
|
|||||||
>
|
>
|
||||||
{item.imageFilenames.length > 0 ? (
|
{item.imageFilenames.length > 0 ? (
|
||||||
<img
|
<img
|
||||||
src={item.imageFilenames[0]}
|
src={getPublicImageUrl(item.imageFilenames[0])}
|
||||||
className="card-img-top"
|
className="card-img-top"
|
||||||
alt={item.name}
|
alt={item.name}
|
||||||
style={{ height: '200px', objectFit: 'cover' }}
|
style={{ height: '200px', objectFit: 'contain', backgroundColor: '#f8f9fa' }}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="bg-light d-flex align-items-center justify-content-center" style={{ height: '200px' }}>
|
<div className="bg-light d-flex align-items-center justify-content-center" style={{ height: '200px' }}>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useParams, useNavigate, useSearchParams } from "react-router-dom";
|
|||||||
import { Item } from "../types";
|
import { Item } from "../types";
|
||||||
import { useAuth } from "../contexts/AuthContext";
|
import { useAuth } from "../contexts/AuthContext";
|
||||||
import { itemAPI, rentalAPI } from "../services/api";
|
import { itemAPI, rentalAPI } from "../services/api";
|
||||||
|
import { getPublicImageUrl } from "../services/uploadService";
|
||||||
import EmbeddedStripeCheckout from "../components/EmbeddedStripeCheckout";
|
import EmbeddedStripeCheckout from "../components/EmbeddedStripeCheckout";
|
||||||
|
|
||||||
const RentItem: React.FC = () => {
|
const RentItem: React.FC = () => {
|
||||||
@@ -343,13 +344,14 @@ const RentItem: React.FC = () => {
|
|||||||
<div className="card-body">
|
<div className="card-body">
|
||||||
{item.imageFilenames && item.imageFilenames[0] && (
|
{item.imageFilenames && item.imageFilenames[0] && (
|
||||||
<img
|
<img
|
||||||
src={item.imageFilenames[0]}
|
src={getPublicImageUrl(item.imageFilenames[0])}
|
||||||
alt={item.name}
|
alt={item.name}
|
||||||
className="img-fluid rounded mb-3"
|
className="img-fluid rounded mb-3"
|
||||||
style={{
|
style={{
|
||||||
width: "100%",
|
width: "100%",
|
||||||
height: "150px",
|
height: "150px",
|
||||||
objectFit: "cover",
|
objectFit: "contain",
|
||||||
|
backgroundColor: "#f8f9fa",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React, { useState, useEffect } from "react";
|
|||||||
import { Link, useNavigate } from "react-router-dom";
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
import { useAuth } from "../contexts/AuthContext";
|
import { useAuth } from "../contexts/AuthContext";
|
||||||
import { rentalAPI, conditionCheckAPI } from "../services/api";
|
import { rentalAPI, conditionCheckAPI } from "../services/api";
|
||||||
|
import { getPublicImageUrl } from "../services/uploadService";
|
||||||
import { Rental } from "../types";
|
import { Rental } from "../types";
|
||||||
import ReviewItemModal from "../components/ReviewModal";
|
import ReviewItemModal from "../components/ReviewModal";
|
||||||
import RentalCancellationModal from "../components/RentalCancellationModal";
|
import RentalCancellationModal from "../components/RentalCancellationModal";
|
||||||
@@ -232,10 +233,10 @@ const Renting: React.FC = () => {
|
|||||||
>
|
>
|
||||||
{rental.item?.imageFilenames && rental.item.imageFilenames[0] && (
|
{rental.item?.imageFilenames && rental.item.imageFilenames[0] && (
|
||||||
<img
|
<img
|
||||||
src={rental.item.imageFilenames[0]}
|
src={getPublicImageUrl(rental.item.imageFilenames[0])}
|
||||||
className="card-img-top"
|
className="card-img-top"
|
||||||
alt={rental.item.name}
|
alt={rental.item.name}
|
||||||
style={{ height: "200px", objectFit: "cover" }}
|
style={{ height: "200px", objectFit: "contain", backgroundColor: "#f8f9fa" }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div className="card-body">
|
<div className="card-body">
|
||||||
|
|||||||
@@ -261,12 +261,16 @@ export const messageAPI = {
|
|||||||
export const forumAPI = {
|
export const forumAPI = {
|
||||||
getPosts: (params?: any) => api.get("/forum/posts", { params }),
|
getPosts: (params?: any) => api.get("/forum/posts", { params }),
|
||||||
getPost: (id: string) => api.get(`/forum/posts/${id}`),
|
getPost: (id: string) => api.get(`/forum/posts/${id}`),
|
||||||
createPost: (formData: FormData) =>
|
createPost: (data: {
|
||||||
api.post("/forum/posts", formData, {
|
title: string;
|
||||||
headers: {
|
content: string;
|
||||||
"Content-Type": "multipart/form-data",
|
category: string;
|
||||||
},
|
tags?: string[];
|
||||||
}),
|
zipCode?: string;
|
||||||
|
latitude?: number;
|
||||||
|
longitude?: number;
|
||||||
|
imageFilenames?: string[];
|
||||||
|
}) => api.post("/forum/posts", data),
|
||||||
updatePost: (id: string, data: any) => api.put(`/forum/posts/${id}`, data),
|
updatePost: (id: string, data: any) => api.put(`/forum/posts/${id}`, data),
|
||||||
deletePost: (id: string) => api.delete(`/forum/posts/${id}`),
|
deletePost: (id: string) => api.delete(`/forum/posts/${id}`),
|
||||||
updatePostStatus: (id: string, status: string) =>
|
updatePostStatus: (id: string, status: string) =>
|
||||||
@@ -275,12 +279,14 @@ export const forumAPI = {
|
|||||||
api.patch(`/forum/posts/${postId}/accept-answer`, { commentId }),
|
api.patch(`/forum/posts/${postId}/accept-answer`, { commentId }),
|
||||||
getMyPosts: () => api.get("/forum/my-posts"),
|
getMyPosts: () => api.get("/forum/my-posts"),
|
||||||
getTags: (params?: any) => api.get("/forum/tags", { params }),
|
getTags: (params?: any) => api.get("/forum/tags", { params }),
|
||||||
createComment: (postId: string, formData: FormData) =>
|
createComment: (
|
||||||
api.post(`/forum/posts/${postId}/comments`, formData, {
|
postId: string,
|
||||||
headers: {
|
data: {
|
||||||
"Content-Type": "multipart/form-data",
|
content: string;
|
||||||
},
|
parentId?: string;
|
||||||
}),
|
imageFilenames?: string[];
|
||||||
|
}
|
||||||
|
) => api.post(`/forum/posts/${postId}/comments`, data),
|
||||||
updateComment: (commentId: string, data: any) =>
|
updateComment: (commentId: string, data: any) =>
|
||||||
api.put(`/forum/comments/${commentId}`, data),
|
api.put(`/forum/comments/${commentId}`, data),
|
||||||
deleteComment: (commentId: string) =>
|
deleteComment: (commentId: string) =>
|
||||||
@@ -342,12 +348,4 @@ export const feedbackAPI = {
|
|||||||
api.post("/feedback", data),
|
api.post("/feedback", data),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helper to construct message image URLs
|
|
||||||
export const getMessageImageUrl = (imagePath: string) =>
|
|
||||||
`${API_BASE_URL}/messages/images/${imagePath}`;
|
|
||||||
|
|
||||||
// Helper to construct forum image URLs
|
|
||||||
export const getForumImageUrl = (imagePath: string) =>
|
|
||||||
`${process.env.REACT_APP_BASE_URL}/uploads/forum/${imagePath}`;
|
|
||||||
|
|
||||||
export default api;
|
export default api;
|
||||||
|
|||||||
195
frontend/src/services/uploadService.ts
Normal file
195
frontend/src/services/uploadService.ts
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
import api from "./api";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the public URL for an image (S3 only)
|
||||||
|
*/
|
||||||
|
export const getPublicImageUrl = (
|
||||||
|
imagePath: string | null | undefined
|
||||||
|
): string => {
|
||||||
|
if (!imagePath) return "";
|
||||||
|
|
||||||
|
// Already a full S3 URL
|
||||||
|
if (imagePath.startsWith("https://") && imagePath.includes("s3.")) {
|
||||||
|
return imagePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
// S3 key (e.g., "profiles/uuid.jpg", "items/uuid.jpg", "forum/uuid.jpg")
|
||||||
|
const s3Bucket = process.env.REACT_APP_S3_BUCKET || "";
|
||||||
|
const s3Region = process.env.REACT_APP_AWS_REGION || "us-east-1";
|
||||||
|
return `https://${s3Bucket}.s3.${s3Region}.amazonaws.com/${imagePath}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface PresignedUrlResponse {
|
||||||
|
uploadUrl: string;
|
||||||
|
key: string;
|
||||||
|
publicUrl: string;
|
||||||
|
expiresAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UploadType =
|
||||||
|
| "profile"
|
||||||
|
| "item"
|
||||||
|
| "message"
|
||||||
|
| "forum"
|
||||||
|
| "condition-check";
|
||||||
|
|
||||||
|
interface UploadOptions {
|
||||||
|
onProgress?: (percent: number) => void;
|
||||||
|
maxRetries?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a presigned URL for uploading a single file
|
||||||
|
*/
|
||||||
|
export async function getPresignedUrl(
|
||||||
|
uploadType: UploadType,
|
||||||
|
file: File
|
||||||
|
): Promise<PresignedUrlResponse> {
|
||||||
|
const response = await api.post("/upload/presign", {
|
||||||
|
uploadType,
|
||||||
|
contentType: file.type,
|
||||||
|
fileName: file.name,
|
||||||
|
fileSize: file.size,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get presigned URLs for uploading multiple files
|
||||||
|
*/
|
||||||
|
export async function getPresignedUrls(
|
||||||
|
uploadType: UploadType,
|
||||||
|
files: File[]
|
||||||
|
): Promise<PresignedUrlResponse[]> {
|
||||||
|
const response = await api.post("/upload/presign-batch", {
|
||||||
|
uploadType,
|
||||||
|
files: files.map((f) => ({
|
||||||
|
contentType: f.type,
|
||||||
|
fileName: f.name,
|
||||||
|
fileSize: f.size,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
return response.data.uploads;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload a file directly to S3 using a presigned URL
|
||||||
|
*/
|
||||||
|
export async function uploadToS3(
|
||||||
|
file: File,
|
||||||
|
uploadUrl: string,
|
||||||
|
options: UploadOptions = {}
|
||||||
|
): Promise<void> {
|
||||||
|
const { onProgress, maxRetries = 3 } = options;
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||||||
|
try {
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const xhr = new XMLHttpRequest();
|
||||||
|
xhr.open("PUT", uploadUrl, true);
|
||||||
|
xhr.setRequestHeader("Content-Type", file.type);
|
||||||
|
|
||||||
|
if (onProgress) {
|
||||||
|
xhr.upload.onprogress = (e) => {
|
||||||
|
if (e.lengthComputable) {
|
||||||
|
onProgress(Math.round((e.loaded / e.total) * 100));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
xhr.onload = () => {
|
||||||
|
if (xhr.status >= 200 && xhr.status < 300) {
|
||||||
|
resolve();
|
||||||
|
} else {
|
||||||
|
reject(new Error(`HTTP ${xhr.status}`));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
xhr.onerror = () => reject(new Error("Network error"));
|
||||||
|
xhr.send(file);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
} catch (error) {
|
||||||
|
if (attempt === maxRetries - 1) throw error;
|
||||||
|
// Exponential backoff
|
||||||
|
await new Promise((r) => setTimeout(r, Math.pow(2, attempt) * 1000));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Confirm that files have been uploaded to S3
|
||||||
|
*/
|
||||||
|
export async function confirmUploads(
|
||||||
|
keys: string[]
|
||||||
|
): Promise<{ confirmed: string[]; total: number }> {
|
||||||
|
const response = await api.post("/upload/confirm", { keys });
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload a single file to S3 (complete flow)
|
||||||
|
*/
|
||||||
|
export async function uploadFile(
|
||||||
|
uploadType: UploadType,
|
||||||
|
file: File,
|
||||||
|
options: UploadOptions = {}
|
||||||
|
): Promise<{ key: string; publicUrl: string }> {
|
||||||
|
// Get presigned URL
|
||||||
|
const presigned = await getPresignedUrl(uploadType, file);
|
||||||
|
|
||||||
|
// Upload to S3
|
||||||
|
await uploadToS3(file, presigned.uploadUrl, options);
|
||||||
|
|
||||||
|
// Confirm upload
|
||||||
|
const { confirmed } = await confirmUploads([presigned.key]);
|
||||||
|
|
||||||
|
if (confirmed.length === 0) {
|
||||||
|
throw new Error("Upload verification failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
return { key: presigned.key, publicUrl: presigned.publicUrl };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload multiple files to S3 (complete flow)
|
||||||
|
*/
|
||||||
|
export async function uploadFiles(
|
||||||
|
uploadType: UploadType,
|
||||||
|
files: File[],
|
||||||
|
options: UploadOptions = {}
|
||||||
|
): Promise<{ key: string; publicUrl: string }[]> {
|
||||||
|
if (files.length === 0) return [];
|
||||||
|
|
||||||
|
// Get presigned URLs for all files
|
||||||
|
const presignedUrls = await getPresignedUrls(uploadType, files);
|
||||||
|
|
||||||
|
// Upload all files in parallel
|
||||||
|
await Promise.all(
|
||||||
|
files.map((file, i) =>
|
||||||
|
uploadToS3(file, presignedUrls[i].uploadUrl, options)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Confirm all uploads
|
||||||
|
const keys = presignedUrls.map((p) => p.key);
|
||||||
|
const { confirmed, total } = await confirmUploads(keys);
|
||||||
|
|
||||||
|
if (confirmed.length < total) {
|
||||||
|
console.warn(`${total - confirmed.length} uploads failed verification`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return presignedUrls
|
||||||
|
.filter((p) => confirmed.includes(p.key))
|
||||||
|
.map((p) => ({ key: p.key, publicUrl: p.publicUrl }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a signed URL for accessing private content (messages, condition-checks)
|
||||||
|
*/
|
||||||
|
export async function getSignedUrl(key: string): Promise<string> {
|
||||||
|
const response = await api.get(
|
||||||
|
`/upload/signed-url/${encodeURIComponent(key)}`
|
||||||
|
);
|
||||||
|
return response.data.url;
|
||||||
|
}
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
export const getImageUrl = (imagePath: string): string => {
|
|
||||||
// Get the base URL without /api
|
|
||||||
const apiUrl = process.env.REACT_APP_API_URL || '';
|
|
||||||
const baseUrl = apiUrl.replace('/api', '');
|
|
||||||
|
|
||||||
// If imagePath already includes the full path, use it
|
|
||||||
if (imagePath.startsWith('/uploads/')) {
|
|
||||||
return `${baseUrl}${imagePath}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Otherwise, construct the full path
|
|
||||||
return `${baseUrl}/uploads/profiles/${imagePath}`;
|
|
||||||
};
|
|
||||||
Reference in New Issue
Block a user