Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 81 additions & 8 deletions backend/src/controllers/notesController.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,31 @@ import Note from "../models/Note.js";

/**
* Get all notes belonging to the authenticated user.
* Supports optional search query and tag filtering.
*/
export async function getAllNotes(req, res) {
try {
const notes = await Note.find({ userId: req.user._id }).sort({ createdAt: -1 });
const { search, tag } = req.query;
const filter = { userId: req.user._id };

// Search filter: match title or content (case-insensitive)
if (search && typeof search === "string" && search.trim()) {
const regex = new RegExp(search.trim(), "i");
filter.$or = [
{ title: regex },
{ content: regex },
];
}
Comment on lines +14 to +20

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Escape special characters in the search string to prevent ReDoS and application errors.

Passing user input directly to new RegExp without escaping special characters can lead to Regular Expression Denial of Service (ReDoS) or cause 500 errors if the user searches for characters like [, *, or ?.

Please escape the search string before using it in the regular expression.

🛡️ Proposed fix to escape regex characters
         if (search && typeof search === "string" && search.trim()) {
-            const regex = new RegExp(search.trim(), "i");
+            const escapedSearch = search.trim().replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+            const regex = new RegExp(escapedSearch, "i");
             filter.$or = [
                 { title: regex },
                 { content: regex },
             ];
         }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (search && typeof search === "string" && search.trim()) {
const regex = new RegExp(search.trim(), "i");
filter.$or = [
{ title: regex },
{ content: regex },
];
}
if (search && typeof search === "string" && search.trim()) {
const escapedSearch = search.trim().replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const regex = new RegExp(escapedSearch, "i");
filter.$or = [
{ title: regex },
{ content: regex },
];
}
🧰 Tools
🪛 ast-grep (0.44.1)

[warning] 14-14: Detects non-literal values in regular expressions
Context: new RegExp(search.trim(), "i")
Note: [CWE-1333] Inefficient Regular Expression Complexity (ReDoS via non-literal RegExp).

(detect-non-literal-regexp)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/src/controllers/notesController.js` around lines 14 - 20, Escape the
trimmed user input before constructing the RegExp in the search filter block of
notesController. Add or reuse a regex-escaping helper, then pass the escaped
value to new RegExp while preserving case-insensitive title and content
matching.

Source: Linters/SAST tools


// Tag filter: match any of the provided tags (comma-separated)
if (tag && typeof tag === "string" && tag.trim()) {
const tags = tag.split(",").map((t) => t.trim()).filter(Boolean);
if (tags.length > 0) {
filter.tags = { $in: tags };
}
}

const notes = await Note.find(filter).sort({ createdAt: -1 });

res.status(200).json(notes);
} catch (error) {
Expand Down Expand Up @@ -51,10 +72,11 @@ export async function getNoteById(req, res) {

/**
* Create a new note associated with the authenticated user's ID.
* Supports an optional tags array.
*/
export async function createNote(req, res) {
try {
const { title, content } = req.body;
const { title, content, tags } = req.body;

if (typeof title !== "string" || typeof content !== "string") {
return res.status(400).json({ message: "Title and content must be strings" });
Expand All @@ -64,10 +86,22 @@ export async function createNote(req, res) {
return res.status(400).json({ message: "Title is required" });
}

// Validate tags if provided
let parsedTags = [];
if (tags !== undefined) {
if (!Array.isArray(tags)) {
return res.status(400).json({ message: "Tags must be an array of strings" });
}
parsedTags = tags
.map((t) => (typeof t === "string" ? t.trim().toLowerCase() : ""))
.filter((t) => t.length > 0);
}
Comment on lines +89 to +98

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Enforce tag limits and uniqueness on the backend.

Although the frontend limits users to 10 tags and prevents duplicates, this validation should also be enforced in the backend controllers to maintain data integrity and prevent malformed API requests from bypassing frontend constraints.

  • backend/src/controllers/notesController.js#L89-L98: Update the createNote tag parsing logic to remove duplicates and reject requests exceeding 10 tags.
  • backend/src/controllers/notesController.js#L147-L155: Apply the same uniqueness and length constraints to the updateNote logic.
📍 Affects 1 file
  • backend/src/controllers/notesController.js#L89-L98 (this comment)
  • backend/src/controllers/notesController.js#L147-L155
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/src/controllers/notesController.js` around lines 89 - 98, Update the
tag parsing in createNote at backend/src/controllers/notesController.js lines
89-98 to normalize tags, remove duplicates, and reject requests with more than
10 tags using a 400 response; apply the same uniqueness and maximum-length
validation in updateNote at lines 147-155, preserving the existing array and
string validation behavior.


const note = new Note({
userId: req.user._id,
title: title.trim(),
content: content.trim(),
tags: parsedTags,
});

const savedNote = await note.save();
Expand All @@ -84,11 +118,12 @@ export async function createNote(req, res) {

/**
* Update a specific note after verifying ownership by the authenticated user.
* Supports updating title, content, and tags.
*/
export async function updateNote(req, res) {
try {
const { id } = req.params;
const { title, content } = req.body;
const { title, content, tags } = req.body;

if (process.env.MONGO_URI && !mongoose.Types.ObjectId.isValid(id)) {
return res.status(400).json({
Expand All @@ -102,14 +137,26 @@ export async function updateNote(req, res) {
if (!title.trim()) {
return res.status(400).json({ message: "Title is required" });
}


// Build update object
const updateData = {
title: title.trim(),
content: content.trim(),
};

// Handle tags update if provided
if (tags !== undefined) {
if (!Array.isArray(tags)) {
return res.status(400).json({ message: "Tags must be an array of strings" });
}
updateData.tags = tags
.map((t) => (typeof t === "string" ? t.trim().toLowerCase() : ""))
.filter((t) => t.length > 0);
}

const updatedNote = await Note.findOneAndUpdate(
{ _id: id, userId: req.user._id },
{
title: title.trim(),
content: content.trim(),
},
updateData,
{
new: true,
runValidators: true,
Expand Down Expand Up @@ -164,3 +211,29 @@ export async function deleteNote(req, res) {
});
}
}

/**
* Get all unique tags used by the authenticated user.
*/
export async function getUserTags(req, res) {
try {
const result = await Note.aggregate([
{ $match: { userId: req.user._id } },
{ $unwind: "$tags" },
{ $group: { _id: "$tags", count: { $sum: 1 } } },
{ $sort: { count: -1, _id: 1 } },
]);

const tags = result.map((t) => ({
name: t._id,
count: t.count,
}));

res.status(200).json(tags);
} catch (error) {
console.error("Error in getUserTags controller:", error);
res.status(500).json({
message: "Internal server error",
});
}
}
5 changes: 5 additions & 0 deletions backend/src/models/Note.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ const noteSchema = new mongoose.Schema({
type: String,
default: ""
},
tags: {
type: [String],
default: [],
index: true
},
isGroup: {
type: Boolean,
default: false
Expand Down
6 changes: 4 additions & 2 deletions backend/src/routes/notesRoutes.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import express from "express";
import { getAllNotes, getNoteById, createNote, updateNote, deleteNote } from "../controllers/notesController.js";
import { getAllNotes, getNoteById, createNote, updateNote, deleteNote, getUserTags } from "../controllers/notesController.js";
import { authenticateUser } from "../middleware/authMiddleware.js";

const router = express.Router();
Expand All @@ -9,6 +9,8 @@ router.use(authenticateUser);

router.get("/", getAllNotes);

router.get("/tags", getUserTags);

router.get("/:id", getNoteById);

router.post("/", createNote);
Expand All @@ -17,4 +19,4 @@ router.put("/:id", updateNote);

router.delete("/:id", deleteNote);

export default router;
export default router;
12 changes: 11 additions & 1 deletion frontend/src/components/NoteCard.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { Link, useNavigate } from "react-router-dom";
import { formatDate } from "../lib/utils";
import api from "../lib/axios";
import toast from "react-hot-toast";
import TagBadge from "./TagBadge";

const NoteCard = ({
note,
Expand Down Expand Up @@ -118,9 +119,18 @@ const NoteCard = ({
</h3>

{/* Content Preview */}
<p className="text-gray-500 dark:text-gray-400 text-sm line-clamp-3 mb-4 leading-relaxed">
<p className="text-gray-500 dark:text-gray-400 text-sm line-clamp-3 mb-3 leading-relaxed">
{note.content || "No content"}
</p>

{/* Tags */}
{note.tags && note.tags.length > 0 && (
<div className="flex flex-wrap gap-1.5 mb-3">
{note.tags.map((tag) => (
<TagBadge key={tag} tag={tag} />
))}
</div>
)}

{/* Footer */}
<div className="flex items-center justify-between mt-2">
Expand Down
34 changes: 32 additions & 2 deletions frontend/src/components/NotesNotFound.jsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,37 @@
import { NotebookIcon } from "lucide-react";
import { NotebookIcon, SearchXIcon } from "lucide-react";
import { Link } from "react-router-dom";

const NotesNotFound = () => {
const NotesNotFound = ({ isFiltered = false }) => {
if (isFiltered) {
return (
<div className="flex flex-col items-center justify-center py-16 space-y-6 max-w-md mx-auto text-center">
{/* Icon Circle */}
<div className="bg-orange-50 dark:bg-orange-950 rounded-full p-6">
<SearchXIcon className="size-12 text-orange-500 dark:text-orange-400" />
</div>

{/* Title */}
<h3 className="text-2xl font-bold text-gray-900 dark:text-white">
No matching notes
</h3>

{/* Description */}
<p className="text-gray-500 dark:text-gray-400">
Try adjusting your search query or clearing the filters to see all your notes.
</p>

{/* Button */}
<Link
to="/create"
className="inline-flex items-center gap-2 px-6 py-3 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg transition-all duration-200 shadow-sm hover:shadow-md"
>
<NotebookIcon size={18} />
Create New Note
</Link>
</div>
);
}

return (
<div className="flex flex-col items-center justify-center py-16 space-y-6 max-w-md mx-auto text-center">
{/* Icon Circle */}
Expand Down
26 changes: 26 additions & 0 deletions frontend/src/components/SearchBar.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { SearchIcon, XIcon } from "lucide-react";

const SearchBar = ({ value, onChange, placeholder = "Search notes..." }) => {
return (
<div className="relative w-full max-w-md">
<SearchIcon className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-gray-400 dark:text-gray-500" />
<input
type="text"
placeholder={placeholder}
value={value}
onChange={(e) => onChange(e.target.value)}
className="w-full pl-10 pr-10 py-2.5 bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl text-sm text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
/>
{value && (
<button
onClick={() => onChange("")}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
>
<XIcon size={16} />
</button>
)}
</div>
);
};

export default SearchBar;
54 changes: 54 additions & 0 deletions frontend/src/components/TagBadge.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { XIcon } from "lucide-react";

const TAG_COLORS = [
"bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300",
"bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300",
"bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300",
"bg-pink-100 text-pink-700 dark:bg-pink-900/40 dark:text-pink-300",
"bg-yellow-100 text-yellow-700 dark:bg-yellow-900/40 dark:text-yellow-300",
"bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300",
"bg-indigo-100 text-indigo-700 dark:bg-indigo-900/40 dark:text-indigo-300",
"bg-teal-100 text-teal-700 dark:bg-teal-900/40 dark:text-teal-300",
"bg-orange-100 text-orange-700 dark:bg-orange-900/40 dark:text-orange-300",
"bg-cyan-100 text-cyan-700 dark:bg-cyan-900/40 dark:text-cyan-300",
];

const getTagColor = (tag) => {
let hash = 0;
for (let i = 0; i < tag.length; i++) {
hash = tag.charCodeAt(i) + ((hash << 5) - hash);
}
return TAG_COLORS[Math.abs(hash) % TAG_COLORS.length];
};

const TagBadge = ({ tag, onRemove, onClick, active = false, size = "sm" }) => {
const sizeClasses = size === "sm"
? "text-xs px-2 py-0.5"
: "text-sm px-3 py-1";

const activeClasses = active
? "ring-2 ring-blue-500 ring-offset-1 dark:ring-offset-gray-800"
: "";

return (
<span
className={`inline-flex items-center gap-1 rounded-full font-medium ${sizeClasses} ${getTagColor(tag)} ${activeClasses} ${onClick ? "cursor-pointer hover:opacity-80 transition-opacity" : ""}`}
onClick={onClick ? (e) => { e.stopPropagation(); onClick(tag); } : undefined}
>
#{tag}
{onRemove && (
<button
onClick={(e) => {
e.stopPropagation();
onRemove(tag);
}}
className="ml-0.5 hover:opacity-70 transition-opacity"
>
<XIcon size={size === "sm" ? 10 : 12} />
</button>
)}
</span>
);
};

export default TagBadge;
55 changes: 55 additions & 0 deletions frontend/src/components/TagFilter.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { useState, useEffect } from "react";
import api from "../lib/axios";
import TagBadge from "./TagBadge";
import { TagsIcon, XIcon } from "lucide-react";

const TagFilter = ({ selectedTag, onSelectTag }) => {
const [tags, setTags] = useState([]);
const [loading, setLoading] = useState(true);

useEffect(() => {
const fetchTags = async () => {
try {
const res = await api.get("/notes/tags");
setTags(res.data);
} catch (error) {
console.log("Error fetching tags:", error);
} finally {
setLoading(false);
}
};
fetchTags();
}, []);

if (loading) return null;

if (tags.length === 0) return null;

return (
<div className="flex flex-wrap items-center gap-2">
<TagsIcon size={16} className="text-gray-400 dark:text-gray-500" />

{/* "All" button */}
{selectedTag && (
<button
onClick={() => onSelectTag(null)}
className="inline-flex items-center gap-1 text-xs px-2.5 py-1 rounded-full bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
>
<XIcon size={12} />
Clear filter
</button>
)}

{tags.map((tag) => (
<TagBadge
key={tag.name}
tag={tag.name}
active={selectedTag === tag.name}
onClick={() => onSelectTag(selectedTag === tag.name ? null : tag.name)}
/>
))}
</div>
);
};

export default TagFilter;
Loading