Skip to content

Commit de7c227

Browse files
committed
aws s3
1 parent b36bdff commit de7c227

File tree

12 files changed

+424
-15
lines changed

12 files changed

+424
-15
lines changed

backend/bun.lock

Lines changed: 236 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

backend/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
"lint": "eslint ."
99
},
1010
"dependencies": {
11+
"@aws-sdk/client-s3": "^3.1020.0",
1112
"bcryptjs": "^3.0.2",
1213
"cookie-parser": "^1.4.7",
1314
"cors": "^2.8.5",
@@ -16,7 +17,8 @@
1617
"jsonwebtoken": "^9.0.2",
1718
"mongoose": "^9.2.3",
1819
"morgan": "^1.10.1",
19-
"multer": "^2.0.2",
20+
"multer": "^2.1.1",
21+
"multer-s3": "^3.0.1",
2022
"ngrok": "^5.0.0-beta.2"
2123
},
2224
"devDependencies": {

backend/src/config/Aws.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
// config/s3.js
2+
import { S3Client } from '@aws-sdk/client-s3';
3+
4+
export const s3 = new S3Client({
5+
region: process.env.AWS_BUCKET_REGION,
6+
credentials: {
7+
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
8+
secretAccessKey: process.env.AWS_ACCESS_SECRET,
9+
},
10+
});

backend/src/config/cloudinary.js

Whitespace-only changes.

backend/src/controllers/crud.controller.js

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import mongoose from "mongoose";
2+
import { DeleteObjectCommand } from "@aws-sdk/client-s3";
3+
import { s3 } from "../config/Aws.js";
24

35
const OBJECT_ID_PATTERN = /^[a-f\d]{24}$/i;
46

@@ -273,6 +275,22 @@ const pickAllowed = (payload, allowedFields) => {
273275

274276
const isAdmin = (req) => req.user && req.user.role === "admin";
275277

278+
const deleteS3Object = async (key) => {
279+
const bucket = process.env.AWS_BUCKET_NAME;
280+
if (!bucket || !key) return;
281+
282+
try {
283+
await s3.send(
284+
new DeleteObjectCommand({
285+
Bucket: bucket,
286+
Key: key,
287+
})
288+
);
289+
} catch (error) {
290+
console.error(`Failed to delete S3 object: ${key}`, error);
291+
}
292+
};
293+
276294
const ensureOwner = (doc, req, ownerField) => {
277295
if (!ownerField || isAdmin(req)) return true;
278296
const ownerId = doc?.[ownerField]?.toString();
@@ -388,16 +406,26 @@ export const buildCrudController = (
388406
const create = async (req, res, next) => {
389407
try {
390408
let payload = sanitizePayload(req.body || {}, req, ownerField, allowedFields);
409+
391410
if (assignOwnerOnCreate) {
392411
payload = assignOwner(payload, req, ownerField, allowAdminOverride);
393412
}
413+
414+
if (req.file) {
415+
const uploadedUrl = req.file.location;
416+
payload.image = uploadedUrl;
417+
payload.images = [uploadedUrl];
418+
payload.imageKey = req.file.key;
419+
}
420+
394421
const created = await Model.create(payload);
422+
395423
res.status(201).json(created);
396424
} catch (error) {
397425
next(error);
398426
}
399427
};
400-
428+
401429
const update = async (req, res, next) => {
402430
try {
403431
const doc = await Model.findById(req.params.id);
@@ -410,8 +438,23 @@ export const buildCrudController = (
410438
throw new Error("Not authorized to update this resource");
411439
}
412440
const updates = sanitizePayload(req.body || {}, req, ownerField, allowedFields);
441+
let previousImageKey = null;
442+
443+
if (req.file) {
444+
previousImageKey = doc.imageKey || null;
445+
const uploadedUrl = req.file.location;
446+
updates.image = uploadedUrl;
447+
updates.images = [uploadedUrl];
448+
updates.imageKey = req.file.key;
449+
}
450+
413451
Object.assign(doc, updates);
414452
const updated = await doc.save();
453+
454+
if (req.file && previousImageKey && previousImageKey !== updated.imageKey) {
455+
await deleteS3Object(previousImageKey);
456+
}
457+
415458
res.json(updated);
416459
} catch (error) {
417460
next(error);
@@ -429,7 +472,14 @@ export const buildCrudController = (
429472
res.status(403);
430473
throw new Error("Not authorized to delete this resource");
431474
}
475+
476+
const imageKeyToDelete = doc.imageKey || null;
432477
await doc.deleteOne();
478+
479+
if (imageKeyToDelete) {
480+
await deleteS3Object(imageKeyToDelete);
481+
}
482+
433483
res.json({ message: `${resourceName} deleted`, id: req.params.id });
434484
} catch (error) {
435485
next(error);
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import multer from 'multer';
2+
import multerS3 from 'multer-s3';
3+
import { s3 } from '../config/Aws.js';
4+
5+
const upload = multer({
6+
storage: multerS3({
7+
s3,
8+
bucket: process.env.AWS_BUCKET_NAME,
9+
contentType: multerS3.AUTO_CONTENT_TYPE,
10+
key: function (req, file, cb) {
11+
cb(null, `uploads/${Date.now()}-${file.originalname}`);
12+
},
13+
}),
14+
});
15+
16+
export default upload;

backend/src/models/project.model.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,10 @@ const projectSchema = new mongoose.Schema(
3737
type: String,
3838
trim: true,
3939
},
40+
imageKey: {
41+
type: String,
42+
trim: true,
43+
},
4044
liveDemo: {
4145
type: String,
4246
trim: true,

backend/src/routes/project.routes.js

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,19 +7,20 @@ import {
77
updateProject,
88
} from '../controllers/project.controller.js';
99
import { protect, requireRole } from '../middleware/auth.middleware.js';
10+
import upload from '../middleware/aws.middleware.js';
1011

1112
const router = express.Router();
1213

1314
router
1415
.route('/')
1516
.get(getProjects)
16-
.post(protect, requireRole('developer', 'admin'), createProject);
17+
.post(protect, requireRole('developer', 'admin'),upload.single("image"), createProject);
1718

1819
router
1920
.route('/:id')
2021
.get(getProjectById)
21-
.put(protect, requireRole('developer', 'admin'), updateProject)
22-
.patch(protect, requireRole('developer', 'admin'), updateProject)
22+
.put(protect, requireRole('developer', 'admin'),upload.single("image"), updateProject)
23+
.patch(protect, requireRole('developer', 'admin'),upload.single("image"), updateProject)
2324
.delete(protect, requireRole('developer', 'admin'), deleteProject);
2425

2526
export default router;

frontend/src/api/projects.js

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,44 @@ const buildParams = ({
4343
return params;
4444
};
4545

46+
const isFileLike = (value) =>
47+
typeof File !== "undefined" && value instanceof File;
48+
49+
const isFileListLike = (value) =>
50+
typeof FileList !== "undefined" && value instanceof FileList;
51+
52+
const extractImageFile = (payload = {}) => {
53+
const raw = payload.imageFile;
54+
if (!raw) return null;
55+
if (isFileLike(raw)) return raw;
56+
if (isFileListLike(raw)) return raw[0] || null;
57+
if (Array.isArray(raw)) return raw[0] || null;
58+
return null;
59+
};
60+
61+
const buildRequestBody = (payload = {}) => {
62+
const imageFile = extractImageFile(payload);
63+
if (!imageFile) return payload;
64+
65+
const body = new FormData();
66+
Object.entries(payload).forEach(([key, value]) => {
67+
if (key === "imageFile") return;
68+
if (value === undefined || value === null || value === "") return;
69+
70+
if (Array.isArray(value)) {
71+
value
72+
.filter((item) => item !== undefined && item !== null && item !== "")
73+
.forEach((item) => body.append(`${key}[]`, item));
74+
return;
75+
}
76+
77+
body.append(key, value);
78+
});
79+
80+
body.append("image", imageFile);
81+
return body;
82+
};
83+
4684
const fetchProjects = async ({
4785
search,
4886
page,
@@ -136,7 +174,7 @@ export const useCreateProject = () => {
136174
const queryClient = useQueryClient();
137175
return useMutation({
138176
mutationFn: async (payload) => {
139-
const { data } = await http.post("/api/projects", payload);
177+
const { data } = await http.post("/api/projects", buildRequestBody(payload));
140178
return data;
141179
},
142180
onSuccess: (created) => {
@@ -155,7 +193,10 @@ export const useUpdateProject = () => {
155193
const queryClient = useQueryClient();
156194
return useMutation({
157195
mutationFn: async ({ id, payload }) => {
158-
const { data } = await http.patch(`/api/projects/${id}`, payload);
196+
const { data } = await http.patch(
197+
`/api/projects/${id}`,
198+
buildRequestBody(payload)
199+
);
159200
return data;
160201
},
161202
onSuccess: (updated, variables) => {

frontend/src/components/project/ProjectForm.jsx

Lines changed: 38 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import {
22
Save,
33
Trash2,
44
Image,
5+
Upload,
56
FileText,
67
Tag,
78
Link2,
@@ -12,7 +13,7 @@ import {
1213
Sparkles,
1314
} from "lucide-react";
1415
import { useFieldArray, useForm } from "react-hook-form";
15-
import React, { useEffect } from "react";
16+
import React, { useEffect, useState } from "react";
1617
import Button from "../common/Button";
1718

1819
const InputField = ({ label, icon: Icon, error, children, required }) => (
@@ -159,7 +160,29 @@ const ProjectForm = ({
159160
}, [defaultValues, reset]);
160161

161162
const watchedImage = watch("image");
163+
const watchedImageFile = watch("imageFile");
162164
const watchedImages = watch("images") || [];
165+
const [filePreviewUrl, setFilePreviewUrl] = useState("");
166+
167+
const selectedImageFile = Array.isArray(watchedImageFile)
168+
? watchedImageFile[0]
169+
: watchedImageFile?.[0] || null;
170+
171+
useEffect(() => {
172+
if (!selectedImageFile) {
173+
setFilePreviewUrl("");
174+
return undefined;
175+
}
176+
177+
const objectUrl = URL.createObjectURL(selectedImageFile);
178+
setFilePreviewUrl(objectUrl);
179+
return () => URL.revokeObjectURL(objectUrl);
180+
}, [selectedImageFile]);
181+
182+
const mainPreviewImage =
183+
filePreviewUrl ||
184+
(typeof watchedImage === "string" ? watchedImage.trim() : "");
185+
163186
const previewImages = Array.isArray(watchedImages)
164187
? watchedImages.filter((value) => typeof value === "string" && value.trim())
165188
: [];
@@ -171,11 +194,11 @@ const ProjectForm = ({
171194
<div className="max-w-4xl mx-auto p-6">
172195
<div className="bg-white rounded-xl shadow-lg border border-gray-100">
173196
<form onSubmit={handleSubmit(onSubmit)} className="p-6 space-y-6">
174-
{(watchedImage || previewImages.length > 0) && (
197+
{(mainPreviewImage || previewImages.length > 0) && (
175198
<div className="flex flex-col items-center gap-3">
176-
{watchedImage && (
199+
{mainPreviewImage && (
177200
<img
178-
src={watchedImage}
201+
src={mainPreviewImage}
179202
alt="Project preview"
180203
className="w-28 h-28 object-cover rounded-lg border border-gray-200"
181204
onError={(e) => e.target.classList.add("hidden")}
@@ -199,15 +222,24 @@ const ProjectForm = ({
199222

200223
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
201224
<div className="space-y-4">
202-
<InputField label="Feed Image" icon={Image} error={errors.image}>
225+
<InputField label="Upload Cover Image" icon={Upload} error={errors.imageFile}>
226+
<input
227+
{...register("imageFile")}
228+
type="file"
229+
accept="image/*"
230+
className={`${inputStyles} file:mr-3 file:rounded file:border-0 file:bg-gray-900 file:px-3 file:py-2 file:text-xs file:font-semibold file:text-white`}
231+
/>
232+
</InputField>
233+
234+
<InputField label="Cover Image URL (optional)" icon={Image} error={errors.image}>
203235
<input
204236
{...register("image", {
205237
pattern: {
206238
value: URL_PATTERN,
207239
message: "Please enter a valid image URL",
208240
},
209241
})}
210-
placeholder="https://example.com/feed.jpg"
242+
placeholder="https://example.com/cover.jpg"
211243
type="url"
212244
className={inputStyles}
213245
/>

0 commit comments

Comments
 (0)