Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
140 changes: 140 additions & 0 deletions src/app/api/report-tag/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import { NextResponse } from "next/server";
import { connectToDatabase } from "@/lib/database/mongoose";
import TagReport from "@/db/tagReport";

const ipCounters = new Map<string, { count: number; resetAt: number }>();
Copy link

Copilot AI Nov 27, 2025

Choose a reason for hiding this comment

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

The ipCounters Map will grow indefinitely and never clear expired entries, causing a memory leak over time. Implement a cleanup mechanism to periodically remove expired entries or use a time-based cache with automatic expiration (e.g., node-cache or lru-cache).

Suggested change
const ipCounters = new Map<string, { count: number; resetAt: number }>();
import NodeCache from "node-cache";
const ipCounters = new NodeCache({ stdTTL: IP_WINDOW_MS / 1000, checkperiod: 60 });

Copilot uses AI. Check for mistakes.
Copy link

Copilot AI Nov 27, 2025

Choose a reason for hiding this comment

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

The in-memory rate limiting using Map will be reset when the server restarts or in serverless environments where each request might run on a different instance. This makes the rate limiting unreliable. Consider using a persistent storage solution like Redis or a database-backed rate limiting approach for production reliability.

Copilot uses AI. Check for mistakes.
const IP_LIMIT = 3; // tesing purpose ->> 3 reports/per IP/ per paper/ per hour
Copy link

Copilot AI Nov 27, 2025

Choose a reason for hiding this comment

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

Typo in comment: "tesing" should be "testing".

Suggested change
const IP_LIMIT = 3; // tesing purpose ->> 3 reports/per IP/ per paper/ per hour
const IP_LIMIT = 3; // testing purpose ->> 3 reports/per IP/ per paper/ per hour

Copilot uses AI. Check for mistakes.
const IP_WINDOW_MS = 1000 * 60 * 60;

const ALLOWED_EXAMS = [
"CAT-1",
"CAT-2",
"FAT",
];
const ALLOWED_FIELDS = [
"subject",
"courseCode",
"exam",
"slot",
"year",
];

function getClientIp(req: Request) {
const forwarded = req.headers.get("x-forwarded-for");
if (forwarded) {
const first = forwarded.split(",")[0] ?? forwarded;
return first.trim();
}
const real = req.headers.get("x-real-ip");
if (real) return real;
try {
const url = new URL(req.url);
return url.hostname || "127.0.0.1";
} catch {
return "127.0.0.1";
}
Copy link

Copilot AI Nov 27, 2025

Choose a reason for hiding this comment

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

The getClientIp function can be easily spoofed since it relies on x-forwarded-for and x-real-ip headers which can be manipulated by clients. This undermines the rate limiting mechanism. Consider implementing additional validation or using a more robust IP detection method, especially if not behind a trusted proxy.

Suggested change
function getClientIp(req: Request) {
const forwarded = req.headers.get("x-forwarded-for");
if (forwarded) {
const first = forwarded.split(",")[0] ?? forwarded;
return first.trim();
}
const real = req.headers.get("x-real-ip");
if (real) return real;
try {
const url = new URL(req.url);
return url.hostname || "127.0.0.1";
} catch {
return "127.0.0.1";
}
// WARNING: Cannot reliably determine client IP in this environment.
// The use of x-forwarded-for and x-real-ip headers is insecure unless behind a trusted proxy.
// For secure rate limiting, ensure your deployment is behind a trusted proxy that sets these headers,
// and use a server adapter that exposes the remote address (e.g., req.socket.remoteAddress).
// Here, we return a static value to avoid spoofing.
function getClientIp(req: Request) {
return "unknown";

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

Choose a reason for hiding this comment

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

instead of using "x-forwarded-for" and "x-real-ip"(which can be spoofed) --> req.ip is being used to get the actual ip

Additionally

added a per-paper report limit, so even if someone tries to bypass the IP limit, each paper can only receive a small fixed number of reports.

}

export async function POST(req: Request) {
try {
await connectToDatabase();
const body = (await req.json()) as {
paperId?: string;
reportedFields?: { field: string; value?: string }[];
comment?: string;
reporterEmail?: string;
reporterId?: string;
};

const { paperId } = body;
if (!paperId)
return NextResponse.json(
{ error: "paperId is required" },
{ status: 400 },
);

const ip = getClientIp(req);
const key = `${ip}::${paperId}`;
const now = Date.now();
const entry = ipCounters.get(key);
if (entry && entry.resetAt > now) {
if (entry.count >= IP_LIMIT)
return NextResponse.json(
{ error: "Rate limit exceeded for reporting." },
{ status: 429 },
);
entry.count += 1;
} else {
ipCounters.set(key, { count: 1, resetAt: now + IP_WINDOW_MS });
}

const existingCount = await TagReport.countDocuments({ paperId });
const MAX_REPORTS_PER_PAPER = 2; // for testing purpose kept it to be 2 !!
if (existingCount >= MAX_REPORTS_PER_PAPER)
return NextResponse.json(
{ error: "Received many reports; we are currently working on it." },
{ status: 429 },
);

const reportedFields = (body.reportedFields ?? [])
.map((r) => ({ field: String(r.field).trim(), value: r.value?.trim() }))
.filter((r) => r.field);

for (const rf of reportedFields) {
if (!ALLOWED_FIELDS.includes(rf.field)) {
return NextResponse.json(
{ error: `Invalid field: ${rf.field}` },
{ status: 400 },
);
}
if (rf.field === "exam" && rf.value) {
if (!ALLOWED_EXAMS.includes(rf.value))
return NextResponse.json(
{ error: `Invalid exam value: ${rf.value}` },
{ status: 400 },
);
}
if (rf.field === "year" && rf.value) {
const val = rf.value.trim();

const rangeMatch = val.match(/^(\d{4})[-/](\d{4})$/);

if (rangeMatch) {
const start = Number(rangeMatch[1]);
const end = Number(rangeMatch[2]);
if (
start < 1900 || start > 2100 ||
end < 1900 || end > 2100 ||
end < start
) {
return NextResponse.json(
{ error: `Invalid year range: ${rf.value}` },
{ status: 400 }
);
}
continue;
}
Copy link

Copilot AI Nov 27, 2025

Choose a reason for hiding this comment

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

The year validation logic is incomplete. After checking the year range format, the code continues to the next iteration but doesn't validate single year formats (e.g., "2024"). A single year should also be validated to ensure it falls within the 1900-2100 range. Add validation for single year format after the range check.

Suggested change
}
}
// Validate single year format
const singleYearMatch = val.match(/^(\d{4})$/);
if (singleYearMatch) {
const year = Number(singleYearMatch[1]);
if (year < 1900 || year > 2100) {
return NextResponse.json(
{ error: `Invalid year: ${rf.value}` },
{ status: 400 }
);
}
continue;
}
// If not a valid range or single year, reject
return NextResponse.json(
{ error: `Invalid year format: ${rf.value}` },
{ status: 400 }
);

Copilot uses AI. Check for mistakes.
}

}

const newReport = await TagReport.create({
paperId,
reportedFields,
comment: body.comment,
reporterEmail: body.reporterEmail,
reporterId: body.reporterId,
});

return NextResponse.json(
{ message: "Report submitted.", report: newReport },
{ status: 201 },
);
} catch (err) {
console.error(err);
return NextResponse.json(
{ error: "Failed to submit tag report." },
{ status: 500 },
);
}
}
5 changes: 5 additions & 0 deletions src/app/paper/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,11 @@ const PaperPage = async ({ params }: { params: { id: string } }) => {
<PdfViewer
url={paper.file_url}
name={`${extractBracketContent(paper.subject)}-${paper.exam}-${paper.slot}-${paper.year}`}
paperId={params.id}
subject={paper.subject}
exam={paper.exam}
slot={paper.slot}
year={paper.year}
></PdfViewer>
</center>
<RelatedPapers />
Expand Down
39 changes: 39 additions & 0 deletions src/components/ReportButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
"use client";

import { useState } from "react";
import { FaFlag } from "react-icons/fa6";
import { Button } from "./ui/button";
import ReportTagModal from "./ReportTagModal";

export default function ReportButton({
paperId, subject, exam, slot, year
}: {
paperId: string;
subject?: string;
exam?: string;
slot?: string;
year?: string;
}) {
const [open, setOpen] = useState(false);

return (
<>
<Button
onClick={() => setOpen(true)}
className="h-10 w-10 rounded p-0 text-white transition hover:bg-red-600 bg-red-500"
Copy link

Copilot AI Nov 27, 2025

Choose a reason for hiding this comment

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

The Tailwind CSS class order is inconsistent. Following best practices, utility classes should be ordered: layout β†’ display β†’ positioning β†’ sizing β†’ spacing β†’ colors β†’ effects. Consider reordering to: h-10 w-10 rounded bg-red-500 p-0 text-white transition hover:bg-red-600.

Suggested change
className="h-10 w-10 rounded p-0 text-white transition hover:bg-red-600 bg-red-500"
className="h-10 w-10 p-0 rounded bg-red-500 text-white hover:bg-red-600 transition"

Copilot uses AI. Check for mistakes.
>
<FaFlag className="text-sm" />
</Button>

<ReportTagModal
paperId={paperId}
subject={subject}
exam={exam}
slot={slot}
year={year}
open={open}
setOpen={setOpen}
/>
</>
);
}
Loading