diff --git a/backend/package-lock.json b/backend/package-lock.json index c3e022f..65b48a5 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -14,8 +14,10 @@ "bcryptjs": "^3.0.3", "cookie-parser": "^1.4.7", "cors": "^2.8.5", + "csurf": "^1.11.0", "dotenv": "^16.5.0", "express": "^4.18.2", + "express-rate-limit": "^8.6.1", "jsonwebtoken": "^9.0.3", "mongodb": "^7.2.0", "mongoose": "^8.14.3", @@ -352,6 +354,94 @@ "node": ">= 0.10" } }, + "node_modules/csrf": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/csrf/-/csrf-3.1.0.tgz", + "integrity": "sha512-uTqEnCvWRk042asU6JtapDTcJeeailFy4ydOQS28bj1hcLnYRiqi8SsD2jS412AY1I/4qdOwWZun774iqywf9w==", + "license": "MIT", + "dependencies": { + "rndm": "1.2.0", + "tsscmp": "1.0.6", + "uid-safe": "2.1.5" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/csurf": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/csurf/-/csurf-1.11.0.tgz", + "integrity": "sha512-UCtehyEExKTxgiu8UHdGvHj4tnpE/Qctue03Giq5gPgMQ9cg/ciod5blZQ5a4uCEenNQjxyGuzygLdKUmee/bQ==", + "deprecated": "This package is archived and no longer maintained. For support, visit https://github.com/expressjs/express/discussions", + "license": "MIT", + "dependencies": { + "cookie": "0.4.0", + "cookie-signature": "1.0.6", + "csrf": "3.1.0", + "http-errors": "~1.7.3" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/csurf/node_modules/cookie": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.0.tgz", + "integrity": "sha512-+Hp8fLp57wnUSt0tY0tHEXh4voZRDnoIrZPqlo3DPiI4y9lwg/jqx+1Om94/W6ZaPDOUbnjOt/99w66zk+l1Xg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/csurf/node_modules/depd": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", + "integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/csurf/node_modules/http-errors": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.7.3.tgz", + "integrity": "sha512-ZTTX0MWrsQ2ZAhA1cejAwDLycFsd7I7nVtnkT3Ol0aqodaKW+0CTZDQ1uBv5whptCnc8e8HeRRJxRs0kmm/Qfw==", + "license": "MIT", + "dependencies": { + "depd": "~1.1.2", + "inherits": "2.0.4", + "setprototypeof": "1.1.1", + "statuses": ">= 1.5.0 < 2", + "toidentifier": "1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/csurf/node_modules/setprototypeof": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.1.tgz", + "integrity": "sha512-JvdAWfbXeIGaZ9cILp38HntZSFSo3mWg6xGcJJsd+d4aRMOqauag1C63dJfDw7OaMYwEbHMOxEZ1lqVRYP2OAw==", + "license": "ISC" + }, + "node_modules/csurf/node_modules/statuses": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", + "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/csurf/node_modules/toidentifier": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.0.tgz", + "integrity": "sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, "node_modules/debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", @@ -517,6 +607,48 @@ "node": ">= 0.10.0" } }, + "node_modules/express-rate-limit": { + "version": "8.6.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.6.1.tgz", + "integrity": "sha512-0D493aP61w0TJ2A0wy27riRsO7FMQ7FK+KUHOKCSfPvYo0R55aiC6emCVgFUeShH0fq0ICPVzNcgoS+BsbXQCA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/express-rate-limit/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/express-rate-limit/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -727,6 +859,15 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/ip-address": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.3.1.tgz", + "integrity": "sha512-1e9d3kb97NHJTIJDZW9rKqW2h6+dFa50Dy0fpPSMQp2ADje5gvKsXmdiK6dwY5t76TaTt5+P5N1Y/LoToIxP6g==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -1359,6 +1500,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/random-bytes": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz", + "integrity": "sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -1396,6 +1546,12 @@ "node": ">=8.10.0" } }, + "node_modules/rndm": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/rndm/-/rndm-1.2.0.tgz", + "integrity": "sha512-fJhQQI5tLrQvYIYFpOnFinzv9dwmR7hRnUz1XqP3OJ1jIweTNOd6aTO4jwQSgcBSFUB+/KHJxuGneime+FdzOw==", + "license": "MIT" + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -1651,6 +1807,15 @@ "node": ">=18" } }, + "node_modules/tsscmp": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/tsscmp/-/tsscmp-1.0.6.tgz", + "integrity": "sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==", + "license": "MIT", + "engines": { + "node": ">=0.6.x" + } + }, "node_modules/type-is": { "version": "1.6.18", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", @@ -1664,6 +1829,18 @@ "node": ">= 0.6" } }, + "node_modules/uid-safe": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz", + "integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==", + "license": "MIT", + "dependencies": { + "random-bytes": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/uncrypto": { "version": "0.1.3", "resolved": "https://registry.npmmirror.com/uncrypto/-/uncrypto-0.1.3.tgz", diff --git a/backend/package.json b/backend/package.json index 344efe8..1df4adb 100644 --- a/backend/package.json +++ b/backend/package.json @@ -17,8 +17,10 @@ "bcryptjs": "^3.0.3", "cookie-parser": "^1.4.7", "cors": "^2.8.5", + "csurf": "^1.11.0", "dotenv": "^16.5.0", "express": "^4.18.2", + "express-rate-limit": "^8.6.1", "jsonwebtoken": "^9.0.3", "mongodb": "^7.2.0", "mongoose": "^8.14.3", diff --git a/backend/src/controllers/notesController.js b/backend/src/controllers/notesController.js index aad32a3..65852fe 100644 --- a/backend/src/controllers/notesController.js +++ b/backend/src/controllers/notesController.js @@ -6,7 +6,7 @@ import Note from "../models/Note.js"; */ export async function getAllNotes(req, res) { try { - const notes = await Note.find({ userId: req.user._id }).sort({ createdAt: -1 }); + const notes = await Note.find({ userId: req.user._id }).sort({ position: 1, createdAt: -1 }); res.status(200).json(notes); } catch (error) { @@ -164,3 +164,140 @@ export async function deleteNote(req, res) { }); } } + +/** + * Reorder notes (drag and drop) + */ +export async function reorderNotes(req, res) { + try { + const { id } = req.params; // Dragged note ID + const { targetId, groupId } = req.body; + + if (!mongoose.Types.ObjectId.isValid(id)) { + return res.status(400).json({ message: "Invalid note id" }); + } + if (targetId && !mongoose.Types.ObjectId.isValid(targetId)) { + return res.status(400).json({ message: "Invalid target id" }); + } + if (groupId && !mongoose.Types.ObjectId.isValid(groupId)) { + return res.status(400).json({ message: "Invalid group id" }); + } + + const draggedNote = await Note.findOne({ _id: new mongoose.Types.ObjectId(id), userId: req.user._id }); + if (!draggedNote) { + return res.status(404).json({ message: "Note not found" }); + } + + const targetParentId = groupId ? new mongoose.Types.ObjectId(groupId) : null; + + // Fetch sibling notes in target container sorted by current position + const siblingNotes = await Note.find({ + userId: req.user._id, + parentId: targetParentId + }).sort({ position: 1, createdAt: -1 }); + + // Filter out the dragged note if it exists in the siblings list + const filteredSiblings = siblingNotes.filter( + (n) => n._id.toString() !== String(id) + ); + + // Find position to insert + if (targetId) { + const targetIndex = filteredSiblings.findIndex( + (n) => n._id.toString() === String(targetId) + ); + if (targetIndex !== -1) { + filteredSiblings.splice(targetIndex, 0, draggedNote); + } else { + filteredSiblings.push(draggedNote); + } + } else { + filteredSiblings.push(draggedNote); + } + + // Prepare bulk write operations to update positions & parentId for all siblings + const bulkOps = filteredSiblings.map((n, index) => ({ + updateOne: { + filter: { _id: n._id }, + update: { $set: { position: index, parentId: targetParentId } } + } + })); + + if (bulkOps.length > 0) { + await Note.bulkWrite(bulkOps); + } + + res.status(200).json({ message: "Notes reordered successfully" }); + } catch (error) { + console.error("Error in reorderNotes controller:", error); + res.status(500).json({ message: "Internal server error" }); + } +} + +/** + * Combine notes into a stack/group + */ +export async function groupNotes(req, res) { + try { + const { sourceId, targetId, title } = req.body; + + if (typeof title !== "string" || !title.trim()) { + return res.status(400).json({ message: "sourceId, targetId, and title are required" }); + } + + if (!sourceId || !targetId || !mongoose.Types.ObjectId.isValid(sourceId) || !mongoose.Types.ObjectId.isValid(targetId)) { + return res.status(400).json({ message: "Invalid note IDs" }); + } + + const sourceNote = await Note.findOne({ _id: new mongoose.Types.ObjectId(sourceId), userId: req.user._id }); + const targetNote = await Note.findOne({ _id: new mongoose.Types.ObjectId(targetId), userId: req.user._id }); + + if (!sourceNote || !targetNote) { + return res.status(404).json({ message: "Source or target note not found" }); + } + + // If target is already a group, add the source note to it + if (targetNote.isGroup) { + // Find max position among existing children in this group + const existingChildren = await Note.find({ + userId: req.user._id, + parentId: targetNote._id + }).sort({ position: -1 }); + + const newPosition = existingChildren.length > 0 ? (existingChildren[0].position + 1) : 0; + + sourceNote.parentId = targetNote._id; + sourceNote.position = newPosition; + await sourceNote.save(); + + return res.status(200).json(targetNote); + } + + // Create a new group note + const groupNote = new Note({ + userId: req.user._id, + title: title.trim(), + content: "", + isGroup: true, + parentId: null, + position: targetNote.position || 0 + }); + + const savedGroup = await groupNote.save(); + + // Update source and target notes to point to the new group note + sourceNote.parentId = savedGroup._id; + sourceNote.position = 0; + + targetNote.parentId = savedGroup._id; + targetNote.position = 1; + + await Promise.all([sourceNote.save(), targetNote.save()]); + + res.status(201).json(savedGroup); + } catch (error) { + console.error("Error in groupNotes controller:", error); + res.status(500).json({ message: "Internal server error" }); + } +} + diff --git a/backend/src/middleware/rateLimiter.js b/backend/src/middleware/rateLimiter.js index 7effb36..ed952d7 100644 --- a/backend/src/middleware/rateLimiter.js +++ b/backend/src/middleware/rateLimiter.js @@ -1,8 +1,15 @@ - +import rateLimit from "express-rate-limit"; import ratelimit from "../config/upstash.js"; +const fallbackLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, + max: 100, + standardHeaders: true, + legacyHeaders: false, + message: { message: "Too many requests please try after some time" }, +}); + const rateLimiter = async (req, res, next) => { - if (process.env.NODE_ENV !== "production") { return next(); } @@ -10,14 +17,14 @@ const rateLimiter = async (req, res, next) => { try { const ip = req.headers["x-forwarded-for"]?.split(",")[0].trim() || req.socket.remoteAddress || req.ip; const identifier = req.user?._id || ip || "global-rate-limit"; - const { success, limit, remaining, reset } = await ratelimit.limit(identifier); + const { success } = await ratelimit.limit(identifier); if (!success) { return res.status(429).json({ message: "Too many requests please try after some time" }); } - next(); + return next(); } catch (error) { - console.error("Rate limiter error (failing open):", error); - next(); + console.error("Rate limiter error (failing open to fallback):", error); + return fallbackLimiter(req, res, next); } }; diff --git a/backend/src/models/Note.js b/backend/src/models/Note.js index 7beb664..b9db9b4 100644 --- a/backend/src/models/Note.js +++ b/backend/src/models/Note.js @@ -32,9 +32,17 @@ const noteSchema = new mongoose.Schema({ default: 0 } }, - {timestamps: true}//this will automatically add createdAt and updatedAt fields + { + timestamps: true, + toJSON: { virtuals: true }, + toObject: { virtuals: true } + } ); +noteSchema.virtual("groupId").get(function () { + return this.parentId; +}); + const Note = mongoose.model("Note", noteSchema); export default Note; \ No newline at end of file diff --git a/backend/src/routes/notesRoutes.js b/backend/src/routes/notesRoutes.js index 1160275..1d56c92 100644 --- a/backend/src/routes/notesRoutes.js +++ b/backend/src/routes/notesRoutes.js @@ -1,20 +1,19 @@ import express from "express"; -import { getAllNotes, getNoteById, createNote, updateNote, deleteNote } from "../controllers/notesController.js"; +import { getAllNotes, getNoteById, createNote, updateNote, deleteNote, reorderNotes, groupNotes } from "../controllers/notesController.js"; import { authenticateUser } from "../middleware/authMiddleware.js"; +import rateLimiter from "../middleware/rateLimiter.js"; const router = express.Router(); // Protect all notes routes - only authenticated users can access notes endpoints router.use(authenticateUser); -router.get("/", getAllNotes); - -router.get("/:id", getNoteById); - -router.post("/", createNote); - -router.put("/:id", updateNote); - -router.delete("/:id", deleteNote); +router.get("/", rateLimiter, getAllNotes); +router.post("/group", rateLimiter, groupNotes); +router.get("/:id", rateLimiter, getNoteById); +router.post("/", rateLimiter, createNote); +router.put("/:id", rateLimiter, updateNote); +router.delete("/:id", rateLimiter, deleteNote); +router.patch("/:id/reorder", rateLimiter, reorderNotes); export default router; \ No newline at end of file diff --git a/backend/src/server.js b/backend/src/server.js index 708a199..c234f1b 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -3,6 +3,7 @@ import cors from "cors"; import dotenv from "dotenv"; import path from "path"; import cookieParser from "cookie-parser"; +import csurf from "csurf"; import { fileURLToPath } from "url"; import dns from "dns"; import jwt from "jsonwebtoken"; @@ -48,6 +49,21 @@ if (process.env.NODE_ENV !== "production") { app.use(express.json()); app.use(cookieParser()); +// CSRF Protection Middleware +const csrfMiddleware = csurf({ cookie: { httpOnly: true, sameSite: "lax" }, ignoreMethods: ["GET", "HEAD", "OPTIONS"] }); +const csrfProtection = (req, res, next) => { + if (process.env.NODE_ENV !== "production" || req.headers.authorization?.startsWith("Bearer ")) { + const origin = req.headers.origin || req.headers.referer; + const allowedOrigin = process.env.CLIENT_URL || "http://localhost:5173"; + if (["POST", "PUT", "DELETE", "PATCH"].includes(req.method) && origin && !origin.startsWith(allowedOrigin)) { + return res.status(403).json({ message: "CSRF check failed: unauthorized origin" }); + } + return next(); + } + return csrfMiddleware(req, res, next); +}; +app.use(csrfProtection); + // Optional auth to populate req.user for rateLimiter const optionalAuthenticateUser = (req, res, next) => { try {