From 0c288c4701d9ddd909f2bf3fb1b47043533b4a9f Mon Sep 17 00:00:00 2001 From: Yogesh Date: Wed, 26 Nov 2025 14:39:40 +0530 Subject: [PATCH 1/3] feat: Report tag added a complete tag-reporting system->a backend API with validation + rate limiting, a new DB model (tagreports) to store reports, and a frontend modal. Removed page up and down buttons , replaced by report Flag. --- src/app/api/report-tag/route.ts | 140 ++++++++++ src/app/paper/[id]/page.tsx | 5 + src/components/ReportButton.tsx | 39 +++ src/components/ReportTagModal.tsx | 411 ++++++++++++++++++++++++++++++ src/components/pdfViewer.tsx | 82 ++---- src/db/tagReport.ts | 40 +++ 6 files changed, 660 insertions(+), 57 deletions(-) create mode 100644 src/app/api/report-tag/route.ts create mode 100644 src/components/ReportButton.tsx create mode 100644 src/components/ReportTagModal.tsx create mode 100644 src/db/tagReport.ts diff --git a/src/app/api/report-tag/route.ts b/src/app/api/report-tag/route.ts new file mode 100644 index 0000000..6d38416 --- /dev/null +++ b/src/app/api/report-tag/route.ts @@ -0,0 +1,140 @@ +import { NextResponse } from "next/server"; +import { connectToDatabase } from "@/lib/database/mongoose"; +import TagReport from "@/db/tagReport"; + +const ipCounters = new Map(); +const IP_LIMIT = 3; // tesing purpose ->> 3 reports/per IP/ per paper/ per hour +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"; + } +} + +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; + } +} + + } + + 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 }, + ); + } +} diff --git a/src/app/paper/[id]/page.tsx b/src/app/paper/[id]/page.tsx index c94af26..e353df0 100644 --- a/src/app/paper/[id]/page.tsx +++ b/src/app/paper/[id]/page.tsx @@ -167,6 +167,11 @@ const PaperPage = async ({ params }: { params: { id: string } }) => { diff --git a/src/components/ReportButton.tsx b/src/components/ReportButton.tsx new file mode 100644 index 0000000..df3bd5b --- /dev/null +++ b/src/components/ReportButton.tsx @@ -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 ( + <> + + + + + ); +} diff --git a/src/components/ReportTagModal.tsx b/src/components/ReportTagModal.tsx new file mode 100644 index 0000000..6f009cb --- /dev/null +++ b/src/components/ReportTagModal.tsx @@ -0,0 +1,411 @@ +"use client"; + +import React, { useState, useEffect, useMemo } from "react"; +import { FaFlag } from "react-icons/fa"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { MultiSelect } from "@/components/multi-select"; +import axios from "axios"; +import toast from "react-hot-toast"; + +interface ReportTagModalProps { + paperId: string; + subject?: string; + exam?: string; + slot?: string; + year?: string; + toolbarStyle?: boolean; + open?: boolean; + setOpen?: (v: boolean) => void; +} + +const ReportTagModal = ({ + paperId, + subject, + exam, + slot, + year, + toolbarStyle = false, + open, + setOpen, +}: ReportTagModalProps) => { + const [internalOpen, setInternalOpen] = useState(false); + const isControlled = open !== undefined && setOpen !== undefined; + + const modalOpen = isControlled ? open! : internalOpen; + const modalSetOpen = isControlled ? setOpen! : setInternalOpen; + const [comment, setComment] = useState(""); + const [email, setEmail] = useState(""); + const [selectedCategories, setSelectedCategories] = useState([]); + const [categoryValues, setCategoryValues] = useState>( + {}, + ); + const [originalCategoryValues, setOriginalCategoryValues] = useState< + Record + >({}); + const [originalComment, setOriginalComment] = useState(""); + const [originalEmail, setOriginalEmail] = useState(""); + const [loading, setLoading] = useState(false); + + const SCROLL_THRESHOLD = 4; + const contentClass = `bg-[#F3F5FF] dark:bg-[#070114] border-[#3A3745] items-start sm:max-w-3xl w-full ${ + selectedCategories.length > SCROLL_THRESHOLD + ? "max-h-[70vh] overflow-y-auto" + : "" + }`; + + const isDirty = useMemo(() => { + if (selectedCategories.length === 0) return false; + for (const c of selectedCategories) { + const curr = (categoryValues[c] || "").trim(); + const orig = (originalCategoryValues[c] || "").trim(); + if (curr !== orig) return true; + } + + if (selectedCategories.includes("subject")) { + const currCode = (categoryValues["courseCode"] || "").trim(); + const origCode = (originalCategoryValues["courseCode"] || "").trim(); + if (currCode !== origCode) return true; + } + + return false; + }, [selectedCategories, categoryValues, originalCategoryValues]); + + useEffect(() => { + for (const c of selectedCategories) { + if (categoryValues[c]) continue; + if (c === "subject" && subject) { + const m = subject.match(/^(.*)\s*\[([^\]]+)\]\s*$/); + if (m?.[1] && m?.[2]) { + const name = m[1].trim(); + const code = m[2].trim(); + setCategoryValues((s) => ({ ...s, subject: name, courseCode: code })); + } else { + setCategoryValues((s) => ({ ...s, subject })); + } + } else if (c === "exam" && exam) + setCategoryValues((s) => ({ ...s, [c]: exam })); + else if (c === "slot" && slot) + setCategoryValues((s) => ({ ...s, [c]: slot })); + else if (c === "year" && year) + setCategoryValues((s) => ({ ...s, [c]: year })); + } + }, [selectedCategories]); + + useEffect(() => { + if (open) { + const base: Record = {}; + if (subject) { + const m = subject.match(/^(.*)\s*\[([^\]]+)\]\s*$/); + if (m?.[1] && m?.[2]) { + base["subject"] = m[1].trim(); + base["courseCode"] = m[2].trim(); + } else { + base["subject"] = subject; + } + } + if (exam) base["exam"] = exam; + if (slot) base["slot"] = slot; + if (year) base["year"] = year; + setOriginalCategoryValues(base); + setOriginalComment(""); + setOriginalEmail(""); + } else { + setSelectedCategories([]); + setCategoryValues({}); + setComment(""); + setEmail(""); + setOriginalCategoryValues({}); + setOriginalComment(""); + setOriginalEmail(""); + } + }, [open, subject, exam, slot, year]); + + const handleSubmit = async () => { + if (!paperId) { + toast.error("Missing paper id."); + return; + } + + if (selectedCategories.includes("slot")) { + const v = (categoryValues.slot || "").trim(); + const slotRegex = /^[A-G][1-2]$/; + if (!slotRegex.test(v)) { + toast.error("Slot must be from A1 to G2 (e.g., D1, B2)."); + return; + } + } + + if (selectedCategories.includes("year")) { + const y = (categoryValues.year || "").trim(); + const yearRegex = /^\d{4}(-\d{4})?$/; + if (!yearRegex.test(y)) { + toast.error("Year must be a valid format (e.g., 2024 or 2024-2025)."); + return; + } + } + + const reportedFields: { field: string; value?: string }[] = []; + + for (const c of selectedCategories) { + if (c === "subject") { + const newSub = (categoryValues.subject || "").trim(); + const oldSub = (originalCategoryValues.subject || "").trim(); + + if (newSub !== oldSub) { + reportedFields.push({ field: "subject", value: newSub }); + } + + const newCode = (categoryValues.courseCode || "").trim(); + const oldCode = (originalCategoryValues.courseCode || "").trim(); + + if (newCode !== oldCode) { + reportedFields.push({ field: "courseCode", value: newCode }); + } + + continue; + } + + const newVal = (categoryValues[c] || "").trim(); + const oldVal = (originalCategoryValues[c] || "").trim(); + + if (newVal !== oldVal) { + reportedFields.push({ field: c, value: newVal }); + } + } + + if (reportedFields.length === 0) { + toast.error("You haven’t changed anything to report."); + return; + } + + setLoading(true); + try { + await toast.promise( + axios.post("/api/report-tag", { + paperId, + reportedFields, + comment, + reporterEmail: email || undefined, + }), + { + loading: "Submitting report...", + success: "Reported successfully. Thank you — we will work on that.", + error: "Failed to submit report.", + }, + ); + + modalSetOpen(false); + setComment(""); + setEmail(""); + setSelectedCategories([]); + setCategoryValues({}); + } catch (err: any) { + console.error(err); + toast.error(err?.response?.data?.error || "Failed to submit report."); + } finally { + setLoading(false); + } +}; + + return ( + + {!isControlled && ( + + + + )} + + + + Report Wrong Tags + + Help us improve tagging — suggest correct tags and add an optional + comment. + + + +
+
+ + setSelectedCategories(vals)} + defaultValue={[]} + placeholder="Select fields" + /> +
+ + {selectedCategories.length > 0 && ( +
+ +
+ {selectedCategories.map((c) => { + if (c === "subject") { + return ( +
+ + + setCategoryValues((s) => ({ + ...s, + subject: e.target.value, + })) + } + placeholder="Subject name" + className="mb-2 w-full" + /> + + + setCategoryValues((s) => ({ + ...s, + courseCode: e.target.value, + })) + } + placeholder="e.g. BMAT205L" + className="w-full" + /> +
+ ); + } else if (c == "exam") { + return ( +
+ + + +
+ ); + } else if (c == "slot") { + return ( +
+ + { + let v = e.target.value.toUpperCase(); + v = v.replace(/[^A-Z0-9]/g, ""); + + setCategoryValues((s) => ({ ...s, slot: v })); + }} + placeholder="e.g. D1" + className="w-full" + /> +
+ ); + } + else if(c=="year"){ + return ( +
+ + + setCategoryValues((s) => ({ ...s, year: e.target.value })) + } + placeholder="e.g. 2024-2025" + className="w-full" + /> +
+ ); + } + return ( +
+ + + setCategoryValues((s) => ({ + ...s, + [c]: e.target.value, + })) + } + className="w-full" + /> +
+ ); + })} +
+
+ )} + +
+ + setComment(e.target.value)} + placeholder="Short note" + /> +
+ +
+ + setEmail(e.target.value)} + placeholder="you@example.com" + /> +
+ +
+ +
+
+
+
+ ); +}; + +export default ReportTagModal; diff --git a/src/components/pdfViewer.tsx b/src/components/pdfViewer.tsx index c7a72d9..9bd0353 100644 --- a/src/components/pdfViewer.tsx +++ b/src/components/pdfViewer.tsx @@ -1,7 +1,6 @@ "use client"; -import "react-pdf/dist/Page/AnnotationLayer.css"; -import "react-pdf/dist/Page/TextLayer.css"; +import ReportButton from "./ReportButton"; import { useState, useRef, useCallback, useEffect } from "react"; import { Document, Page, pdfjs } from "react-pdf"; import { Download, ZoomIn, ZoomOut, Maximize2, Minimize2 } from "lucide-react"; @@ -9,12 +8,6 @@ import { Button } from "./ui/button"; import { downloadFile } from "../lib/utils/download"; import ShareButton from "./ShareButton"; import Loader from "./ui/loader"; -import { - FaGreaterThan, - FaLessThan, - FaAngleUp, - FaAngleDown, -} from "react-icons/fa6"; pdfjs.GlobalWorkerOptions.workerSrc = "https://cdnjs.cloudflare.com/ajax/libs/pdf.js/4.8.69/pdf.worker.min.mjs"; @@ -22,9 +15,15 @@ pdfjs.GlobalWorkerOptions.workerSrc = interface PdfViewerProps { url: string; name: string; + paperId: string; + subject?: string; + exam?: string; + slot?: string; + year?: string; } -export default function PdfViewer({ url, name }: PdfViewerProps) { +export default function PdfViewer({ url, name, paperId, subject, exam, slot, year}: PdfViewerProps) { + const [reportOpen, setReportOpen] = useState(false); const [numPages, setNumPages] = useState(); const [pageNumber, setPageNumber] = useState(1); const [scale, setScale] = useState(1); @@ -204,14 +203,6 @@ export default function PdfViewer({ url, name }: PdfViewerProps) { {!isFullscreen && (
- - of {numPages ?? 1} -
@@ -265,6 +249,13 @@ export default function PdfViewer({ url, name }: PdfViewerProps) { > {isFullscreen ? : } +
)} @@ -302,8 +293,8 @@ export default function PdfViewer({ url, name }: PdfViewerProps) { @@ -314,13 +305,6 @@ export default function PdfViewer({ url, name }: PdfViewerProps) { {isFullscreen && (
- of {numPages ?? 1} -
@@ -425,24 +402,15 @@ export default function PdfViewer({ url, name }: PdfViewerProps) { /> of {numPages ?? 1}
- - - -
- + + )} ); diff --git a/src/db/tagReport.ts b/src/db/tagReport.ts new file mode 100644 index 0000000..efe5692 --- /dev/null +++ b/src/db/tagReport.ts @@ -0,0 +1,40 @@ +import mongoose, { Schema, type Document, type Model } from "mongoose"; + +export interface ITagReport extends Document { + paperId: string; + comment?: string; + reporterEmail?: string; + reporterId?: string; + reportedFields?: { field: string; value?: string }[]; + resolved: boolean; + createdAt: Date; +} + +const tagReportSchema = new Schema({ + paperId: { type: String, required: true }, + comment: { type: String }, + reporterEmail: { type: String }, + reporterId: { type: String }, + reportedFields: { + type: [ + new Schema( + { + field: { type: String, required: true }, + value: { type: String }, + }, + { _id: false }, + ), + ], + default: [], + }, + resolved: { type: Boolean, default: false }, + createdAt: { type: Date, default: Date.now }, +}); + +tagReportSchema.index({ paperId: 1, resolved: 1, createdAt: -1 }); + +const TagReport: Model = + mongoose.models.TagReport ?? + mongoose.model("TagReport", tagReportSchema); + +export default TagReport; From ac5dac263892b701b11776686445cbf9c1edb192 Mon Sep 17 00:00:00 2001 From: Yogesh Date: Fri, 28 Nov 2025 01:40:10 +0530 Subject: [PATCH 2/3] fix: Upstash Rate Limiter 1) Fixed a few minor issues. 2) Used existing Shadcn components to maintain UI consistency. 3) Implemented Upstash Rate Limiting and set up a Redis database to prevent spoofing(IP) and report spamming. --- package.json | 1 + pnpm-lock.yaml | 150 ++++++++++++++++++------------ src/app/api/report-tag/route.ts | 125 ++++++++----------------- src/components/ReportTagModal.tsx | 137 +++++++++++++++------------ src/components/pdfViewer.tsx | 3 +- src/lib/utils/redis.ts | 6 ++ 6 files changed, 218 insertions(+), 204 deletions(-) create mode 100644 src/lib/utils/redis.ts diff --git a/package.json b/package.json index ffe33c3..22069a8 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "@types/node": "^24.6.1", "@ungap/with-resolvers": "^0.1.0", "@upstash/ratelimit": "^2.0.5", + "@upstash/redis": "^1.33.0", "@vercel/kv": "^3.0.0", "axios": "^1.8.4", "canvas": "^3.2.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 338579c..240913b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -67,7 +67,10 @@ importers: version: 0.1.0 '@upstash/ratelimit': specifier: ^2.0.5 - version: 2.0.6(@upstash/redis@1.35.4) + version: 2.0.7(@upstash/redis@1.33.0) + '@upstash/redis': + specifier: ^1.33.0 + version: 1.33.0 '@vercel/kv': specifier: ^3.0.0 version: 3.0.0 @@ -1045,6 +1048,9 @@ packages: '@types/json5@0.0.29': resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} + '@types/node@24.10.1': + resolution: {integrity: sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==} + '@types/node@24.6.1': resolution: {integrity: sha512-ljvjjs3DNXummeIaooB4cLBKg2U6SPI6Hjra/9rRIy7CpM0HpLtG9HptkMKAb4HYWy5S7HUvJEuWgr/y0U8SHw==} @@ -1241,13 +1247,16 @@ packages: resolution: {integrity: sha512-7qJHGxpQgQr9/vmeS1PktEwvNAF7TI4iJDi8Pu2CFZ9YUGHZH4fOP5TfYlZ4aVxfopnELiE4BS4FBjyK7V1/xQ==} engines: {node: '>=16.0.0'} - '@upstash/ratelimit@2.0.6': - resolution: {integrity: sha512-Uak5qklMfzFN5RXltxY6IXRENu+Hgmo9iEgMPOlUs2etSQas2N+hJfbHw37OUy4vldLRXeD0OzL+YRvO2l5acg==} + '@upstash/ratelimit@2.0.7': + resolution: {integrity: sha512-qNQW4uBPKVk8c4wFGj2S/vfKKQxXx1taSJoSGBN36FeiVBBKHQgsjPbKUijZ9Xu5FyVK+pfiXWKIsQGyoje8Fw==} peerDependencies: '@upstash/redis': ^1.34.3 - '@upstash/redis@1.35.4': - resolution: {integrity: sha512-WE1ZnhFyBiIjTDW13GbO6JjkiMVVjw5VsvS8ENmvvJsze/caMQ5paxVD44+U68IUVmkXcbsLSoE+VIYsHtbQEw==} + '@upstash/redis@1.33.0': + resolution: {integrity: sha512-5WOilc7AE0ITAdE3NCyMwgOq1n3RHcqW0OfmbotiAyfA+QAEe1R7kXin8L/Yladgdc5lkA0GcYyewqKfAw53jQ==} + + '@upstash/redis@1.35.7': + resolution: {integrity: sha512-bdCdKhke+kYUjcLLuGWSeQw7OLuWIx3eyKksyToLBAlGIMX9qiII0ptp8E0y7VFE1yuBxBd/3kSzJ8774Q4g+A==} '@vercel/kv@3.0.0': resolution: {integrity: sha512-pKT8fRnfyYk2MgvyB6fn6ipJPCdfZwiKDdw7vB+HL50rjboEBHDVBEcnwfkEpVSp2AjNtoaOUH7zG+bVC/rvSg==} @@ -1467,8 +1476,8 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} - baseline-browser-mapping@2.8.10: - resolution: {integrity: sha512-uLfgBi+7IBNay8ECBO2mVMGZAc1VgZWEChxm4lv+TobGdG82LnXMjuNGo/BSSZZL4UmkWhxEHP2f5ziLNwGWMA==} + baseline-browser-mapping@2.8.31: + resolution: {integrity: sha512-a28v2eWrrRWPpJSzxc+mKwm0ZtVx/G8SepdQZDArnXYU/XS+IF6mp8aB/4E+hH1tyGCoDo3KlUCdlSxGDsRkAw==} hasBin: true big.js@5.2.2: @@ -1494,8 +1503,8 @@ packages: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} - browserslist@4.26.3: - resolution: {integrity: sha512-lAUU+02RFBuCKQPj/P6NgjlbCnLBMp4UtgTx7vNHd3XSIJF87s9a5rA3aH2yw3GS9DqZAUbOtZdCCiZeVRqt0w==} + browserslist@4.28.0: + resolution: {integrity: sha512-tbydkR/CxfMwelN0vwdP/pLkDwyAASZ+VfWm4EOwlB6SWhx1sYnWLqo8N5j0rAzPfzfRaxt0mM/4wPU/Su84RQ==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true @@ -1543,6 +1552,9 @@ packages: caniuse-lite@1.0.30001746: resolution: {integrity: sha512-eA7Ys/DGw+pnkWWSE/id29f2IcPHVoE8wxtvE5JdvD2V28VTDPy1yEeo11Guz0sJ4ZeGRcm3uaTcAqK1LXaphA==} + caniuse-lite@1.0.30001757: + resolution: {integrity: sha512-r0nnL/I28Zi/yjk1el6ilj27tKcdjLsNqAOZr0yVjWPrSQyHgKI2INaEWw21bAQSv2LXRt1XuCS/GomNpWOxsQ==} + canvas@3.2.0: resolution: {integrity: sha512-jk0GxrLtUEmW/TmFsk2WghvgHe8B0pxGilqCL21y8lHkPUGa6FTsnCNtHPOzT8O3y+N+m3espawV80bbBlgfTA==} engines: {node: ^18.12.0 || >= 20.9.0} @@ -1609,6 +1621,9 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + crypto-js@4.2.0: + resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==} + css-box-model@1.2.1: resolution: {integrity: sha512-a7Vr4Q/kd/aw96bnJG332W9V9LkJO69JRcaCYDUqjp6/z0w6VcZjgAcTbgFxEPfBgdnAwlh3iwu+hLopa+flJw==} @@ -1728,8 +1743,8 @@ packages: ecdsa-sig-formatter@1.0.11: resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} - electron-to-chromium@1.5.228: - resolution: {integrity: sha512-nxkiyuqAn4MJ1QbobwqJILiDtu/jk14hEAWaMiJmNPh1Z+jqoFlBFZjdXwLWGeVSeu9hGLg6+2G9yJaW8rBIFA==} + electron-to-chromium@1.5.262: + resolution: {integrity: sha512-NlAsMteRHek05jRUxUR0a5jpjYq9ykk6+kO0yRaMi5moe7u0fVIOeQ3Y30A8dIiWFBNUoQGi1ljb1i5VtS9WQQ==} embla-carousel-autoplay@8.6.0: resolution: {integrity: sha512-OBu5G3nwaSXkZCo1A6LTaFMZ8EpkYbwIaH+bPqdBnDGQ2fh4+NbzjXjs2SktoPNKCtflfVMc75njaDHOYXcrsA==} @@ -2497,8 +2512,8 @@ packages: lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} - loader-runner@4.3.0: - resolution: {integrity: sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==} + loader-runner@4.3.1: + resolution: {integrity: sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==} engines: {node: '>=6.11.5'} loader-utils@2.0.4: @@ -2722,8 +2737,8 @@ packages: resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - node-releases@2.0.21: - resolution: {integrity: sha512-5b0pgg78U3hwXkCM8Z9b2FJdPZlr9Psr9V2gQPESdGHqbntyFJKFW4r5TeWGFzafGY3hzs1JC62VEQMbl1JFkw==} + node-releases@2.0.27: + resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} normalize-path@3.0.0: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} @@ -3235,8 +3250,8 @@ packages: resolution: {integrity: sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==} engines: {node: '>= 10.13.0'} - schema-utils@4.3.2: - resolution: {integrity: sha512-Gn/JaSk/Mt9gYubxTtSn/QCV4em9mpAPiR1rqy/Ocu19u/G9J5WWdNoUT4SiV6mFC3y6cxyFcFwdzPM3FgxGAQ==} + schema-utils@4.3.3: + resolution: {integrity: sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==} engines: {node: '>= 10.13.0'} semver@6.3.1: @@ -3446,8 +3461,8 @@ packages: engines: {node: '>=14.0.0'} hasBin: true - tapable@2.2.3: - resolution: {integrity: sha512-ZL6DDuAlRlLGghwcfmSn9sK3Hr6ArtyudlSAiCqQ6IfE+b+HHbydbYDIG15IfS5do+7XQQBdBiubF/cV2dnDzg==} + tapable@2.3.0: + resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} engines: {node: '>=6'} tar-fs@2.1.4: @@ -3477,8 +3492,8 @@ packages: uglify-js: optional: true - terser@5.44.0: - resolution: {integrity: sha512-nIVck8DK+GM/0Frwd+nIhZ84pR/BX7rmXMfYwyg+Sri5oGVE99/E3KvXqpC2xHFxyqXyGHTKBSioxxplrO4I4w==} + terser@5.44.1: + resolution: {integrity: sha512-t/R3R/n0MSwnnazuPpPNVO60LX0SKL45pyl9YlvxIdkH0Of7D5qM2EVe+yASRIlY5pZ73nclYJfNANGWPwFDZw==} engines: {node: '>=10'} hasBin: true @@ -3574,11 +3589,14 @@ packages: undici-types@7.13.0: resolution: {integrity: sha512-Ov2Rr9Sx+fRgagJ5AX0qvItZG/JKKoBRAVITs1zk7IqZGTJUwgUr7qoYBpWwakpWilTZFM98rG/AFRocu10iIQ==} + undici-types@7.16.0: + resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + unrs-resolver@1.11.1: resolution: {integrity: sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==} - update-browserslist-db@1.1.3: - resolution: {integrity: sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==} + update-browserslist-db@1.1.4: + resolution: {integrity: sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A==} hasBin: true peerDependencies: browserslist: '>= 4.21.0' @@ -4580,6 +4598,10 @@ snapshots: '@types/json5@0.0.29': {} + '@types/node@24.10.1': + dependencies: + undici-types: 7.16.0 + '@types/node@24.6.1': dependencies: undici-types: 7.13.0 @@ -4779,20 +4801,24 @@ snapshots: '@upstash/core-analytics@0.0.10': dependencies: - '@upstash/redis': 1.35.4 + '@upstash/redis': 1.35.7 - '@upstash/ratelimit@2.0.6(@upstash/redis@1.35.4)': + '@upstash/ratelimit@2.0.7(@upstash/redis@1.33.0)': dependencies: '@upstash/core-analytics': 0.0.10 - '@upstash/redis': 1.35.4 + '@upstash/redis': 1.33.0 - '@upstash/redis@1.35.4': + '@upstash/redis@1.33.0': + dependencies: + crypto-js: 4.2.0 + + '@upstash/redis@1.35.7': dependencies: uncrypto: 0.1.3 '@vercel/kv@3.0.0': dependencies: - '@upstash/redis': 1.35.4 + '@upstash/redis': 1.35.7 '@webassemblyjs/ast@1.14.1': dependencies: @@ -5051,7 +5077,7 @@ snapshots: base64-js@1.5.1: {} - baseline-browser-mapping@2.8.10: {} + baseline-browser-mapping@2.8.31: {} big.js@5.2.2: {} @@ -5078,13 +5104,13 @@ snapshots: dependencies: fill-range: 7.1.1 - browserslist@4.26.3: + browserslist@4.28.0: dependencies: - baseline-browser-mapping: 2.8.10 - caniuse-lite: 1.0.30001746 - electron-to-chromium: 1.5.228 - node-releases: 2.0.21 - update-browserslist-db: 1.1.3(browserslist@4.26.3) + baseline-browser-mapping: 2.8.31 + caniuse-lite: 1.0.30001757 + electron-to-chromium: 1.5.262 + node-releases: 2.0.27 + update-browserslist-db: 1.1.4(browserslist@4.28.0) bson@6.10.4: {} @@ -5126,6 +5152,8 @@ snapshots: caniuse-lite@1.0.30001746: {} + caniuse-lite@1.0.30001757: {} + canvas@3.2.0: dependencies: node-addon-api: 7.1.1 @@ -5200,6 +5228,8 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + crypto-js@4.2.0: {} + css-box-model@1.2.1: dependencies: tiny-invariant: 1.3.3 @@ -5303,7 +5333,7 @@ snapshots: dependencies: safe-buffer: 5.2.1 - electron-to-chromium@1.5.228: {} + electron-to-chromium@1.5.262: {} embla-carousel-autoplay@8.6.0(embla-carousel@8.6.0): dependencies: @@ -5334,7 +5364,7 @@ snapshots: enhanced-resolve@5.18.3: dependencies: graceful-fs: 4.2.11 - tapable: 2.2.3 + tapable: 2.3.0 es-abstract@1.24.0: dependencies: @@ -5451,8 +5481,8 @@ snapshots: '@typescript-eslint/parser': 8.45.0(eslint@8.57.1)(typescript@5.9.3) eslint: 8.57.1 eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.45.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.45.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.45.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.45.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1) eslint-plugin-jsx-a11y: 6.10.2(eslint@8.57.1) eslint-plugin-react: 7.37.5(eslint@8.57.1) eslint-plugin-react-hooks: 5.0.0-canary-7118f5dd7-20230705(eslint@8.57.1) @@ -5471,7 +5501,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.45.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.3 @@ -5482,22 +5512,22 @@ snapshots: tinyglobby: 0.2.15 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.45.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.45.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.45.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1) transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.45.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.45.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.45.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1))(eslint@8.57.1): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 8.45.0(eslint@8.57.1)(typescript@5.9.3) eslint: 8.57.1 eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.45.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1) transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.45.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.45.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.45.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -5508,7 +5538,7 @@ snapshots: doctrine: 2.1.0 eslint: 8.57.1 eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.45.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.45.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.45.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1))(eslint@8.57.1) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -6195,7 +6225,7 @@ snapshots: jest-worker@27.5.1: dependencies: - '@types/node': 24.6.1 + '@types/node': 24.10.1 merge-stream: 2.0.0 supports-color: 8.1.1 @@ -6279,7 +6309,7 @@ snapshots: lines-and-columns@1.2.4: {} - loader-runner@4.3.0: {} + loader-runner@4.3.1: {} loader-utils@2.0.4: dependencies: @@ -6465,7 +6495,7 @@ snapshots: fetch-blob: 3.2.0 formdata-polyfill: 4.0.10 - node-releases@2.0.21: {} + node-releases@2.0.27: {} normalize-path@3.0.0: {} @@ -6953,7 +6983,7 @@ snapshots: ajv: 6.12.6 ajv-keywords: 3.5.2(ajv@6.12.6) - schema-utils@4.3.2: + schema-utils@4.3.3: dependencies: '@types/json-schema': 7.0.15 ajv: 8.17.1 @@ -7217,7 +7247,7 @@ snapshots: - tsx - yaml - tapable@2.2.3: {} + tapable@2.3.0: {} tar-fs@2.1.4: dependencies: @@ -7249,12 +7279,12 @@ snapshots: dependencies: '@jridgewell/trace-mapping': 0.3.31 jest-worker: 27.5.1 - schema-utils: 4.3.2 + schema-utils: 4.3.3 serialize-javascript: 6.0.2 - terser: 5.44.0 + terser: 5.44.1 webpack: 5.102.0 - terser@5.44.0: + terser@5.44.1: dependencies: '@jridgewell/source-map': 0.3.11 acorn: 8.15.0 @@ -7363,6 +7393,8 @@ snapshots: undici-types@7.13.0: {} + undici-types@7.16.0: {} + unrs-resolver@1.11.1: dependencies: napi-postinstall: 0.3.3 @@ -7387,9 +7419,9 @@ snapshots: '@unrs/resolver-binding-win32-ia32-msvc': 1.11.1 '@unrs/resolver-binding-win32-x64-msvc': 1.11.1 - update-browserslist-db@1.1.3(browserslist@4.26.3): + update-browserslist-db@1.1.4(browserslist@4.28.0): dependencies: - browserslist: 4.26.3 + browserslist: 4.28.0 escalade: 3.2.0 picocolors: 1.1.1 @@ -7451,7 +7483,7 @@ snapshots: '@webassemblyjs/wasm-parser': 1.14.1 acorn: 8.15.0 acorn-import-phases: 1.0.4(acorn@8.15.0) - browserslist: 4.26.3 + browserslist: 4.28.0 chrome-trace-event: 1.0.4 enhanced-resolve: 5.18.3 es-module-lexer: 1.7.0 @@ -7460,11 +7492,11 @@ snapshots: glob-to-regexp: 0.4.1 graceful-fs: 4.2.11 json-parse-even-better-errors: 2.3.1 - loader-runner: 4.3.0 + loader-runner: 4.3.1 mime-types: 2.1.35 neo-async: 2.6.2 - schema-utils: 4.3.2 - tapable: 2.2.3 + schema-utils: 4.3.3 + tapable: 2.3.0 terser-webpack-plugin: 5.3.14(webpack@5.102.0) watchpack: 2.4.4 webpack-sources: 3.3.3 diff --git a/src/app/api/report-tag/route.ts b/src/app/api/report-tag/route.ts index 6d38416..41b9929 100644 --- a/src/app/api/report-tag/route.ts +++ b/src/app/api/report-tag/route.ts @@ -1,121 +1,76 @@ import { NextResponse } from "next/server"; import { connectToDatabase } from "@/lib/database/mongoose"; import TagReport from "@/db/tagReport"; +import { Ratelimit } from "@upstash/ratelimit"; +import { redis } from "@/lib/utils/redis"; -const ipCounters = new Map(); -const IP_LIMIT = 3; // tesing purpose ->> 3 reports/per IP/ per paper/ per hour -const IP_WINDOW_MS = 1000 * 60 * 60; +const ALLOWED_EXAMS = ["CAT-1", "CAT-2", "FAT"]; +const ALLOWED_FIELDS = ["subject", "courseCode", "exam", "slot", "year"]; -const ALLOWED_EXAMS = [ - "CAT-1", - "CAT-2", - "FAT", -]; -const ALLOWED_FIELDS = [ - "subject", - "courseCode", - "exam", - "slot", - "year", -]; +const ratelimit = new Ratelimit({ + redis, + limiter: Ratelimit.slidingWindow(3, "1 h"),//per id - 3 request - per hour + analytics: true, +}); -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"; - } +function getClientIp(req: any): string { + return req.ip || "127.0.0.1"; } -export async function POST(req: Request) { +export async function POST(req: Request & { ip?: string }) { try { await connectToDatabase(); - const body = (await req.json()) as { - paperId?: string; - reportedFields?: { field: string; value?: string }[]; - comment?: string; - reporterEmail?: string; - reporterId?: string; - }; + const body = await req.json(); const { paperId } = body; - if (!paperId) + + if (!paperId) { return NextResponse.json( { error: "paperId is required" }, - { status: 400 }, + { 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 { success } = await ratelimit.limit(key); + + if (!success) { + return NextResponse.json( + { error: "Rate limit exceeded for reporting." }, + { status: 429 } + ); } + const MAX_REPORTS_PER_PAPER = 5; + const count = await TagReport.countDocuments({ paperId }); - 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) + if (count >= MAX_REPORTS_PER_PAPER) { return NextResponse.json( { error: "Received many reports; we are currently working on it." }, - { status: 429 }, + { status: 429 } ); - + } const reportedFields = (body.reportedFields ?? []) - .map((r) => ({ field: String(r.field).trim(), value: r.value?.trim() })) - .filter((r) => r.field); + .map((r: any) => ({ + field: String(r.field).trim(), + value: r.value?.trim(), + })) + .filter((r: any) => r.field); for (const rf of reportedFields) { if (!ALLOWED_FIELDS.includes(rf.field)) { return NextResponse.json( { error: `Invalid field: ${rf.field}` }, - { status: 400 }, + { status: 400 } ); } if (rf.field === "exam" && rf.value) { - if (!ALLOWED_EXAMS.includes(rf.value)) + if (!ALLOWED_EXAMS.includes(rf.value)) { return NextResponse.json( { error: `Invalid exam value: ${rf.value}` }, - { status: 400 }, + { 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; - } -} - } const newReport = await TagReport.create({ @@ -128,13 +83,13 @@ export async function POST(req: Request) { return NextResponse.json( { message: "Report submitted.", report: newReport }, - { status: 201 }, + { status: 201 } ); } catch (err) { console.error(err); return NextResponse.json( { error: "Failed to submit tag report." }, - { status: 500 }, + { status: 500 } ); } } diff --git a/src/components/ReportTagModal.tsx b/src/components/ReportTagModal.tsx index 6f009cb..d90e241 100644 --- a/src/components/ReportTagModal.tsx +++ b/src/components/ReportTagModal.tsx @@ -13,8 +13,15 @@ import { import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; import { MultiSelect } from "@/components/multi-select"; +import { + Select, + SelectTrigger, + SelectContent, + SelectValue, + SelectItem, +} from "@/components/ui/select"; import axios from "axios"; -import toast from "react-hot-toast"; +import toast from "react-hot-toast"; interface ReportTagModalProps { paperId: string; @@ -51,7 +58,6 @@ const ReportTagModal = ({ const [originalCategoryValues, setOriginalCategoryValues] = useState< Record >({}); - const [originalComment, setOriginalComment] = useState(""); const [originalEmail, setOriginalEmail] = useState(""); const [loading, setLoading] = useState(false); @@ -98,7 +104,7 @@ const ReportTagModal = ({ else if (c === "year" && year) setCategoryValues((s) => ({ ...s, [c]: year })); } - }, [selectedCategories]); + }, [selectedCategories, subject, exam, slot, year]); useEffect(() => { if (open) { @@ -116,7 +122,6 @@ const ReportTagModal = ({ if (slot) base["slot"] = slot; if (year) base["year"] = year; setOriginalCategoryValues(base); - setOriginalComment(""); setOriginalEmail(""); } else { setSelectedCategories([]); @@ -124,7 +129,6 @@ const ReportTagModal = ({ setComment(""); setEmail(""); setOriginalCategoryValues({}); - setOriginalComment(""); setOriginalEmail(""); } }, [open, subject, exam, slot, year]); @@ -135,6 +139,14 @@ const ReportTagModal = ({ return; } + if (selectedCategories.includes("subject")) { + const sub = (categoryValues.subject || "").trim(); + if (!sub) { + toast.error("Subject name cannot be empty."); + return; + } +} + if (selectedCategories.includes("slot")) { const v = (categoryValues.slot || "").trim(); const slotRegex = /^[A-G][1-2]$/; @@ -182,38 +194,41 @@ const ReportTagModal = ({ } } - if (reportedFields.length === 0) { - toast.error("You haven’t changed anything to report."); - return; - } +if (reportedFields.length === 0 && comment.trim().length === 0) { + toast.error("Please change a tag or write a comment."); + return; +} setLoading(true); - try { - await toast.promise( - axios.post("/api/report-tag", { - paperId, - reportedFields, - comment, - reporterEmail: email || undefined, - }), - { - loading: "Submitting report...", - success: "Reported successfully. Thank you — we will work on that.", - error: "Failed to submit report.", - }, - ); - - modalSetOpen(false); - setComment(""); - setEmail(""); - setSelectedCategories([]); - setCategoryValues({}); - } catch (err: any) { - console.error(err); - toast.error(err?.response?.data?.error || "Failed to submit report."); - } finally { - setLoading(false); - } + + try { + const res = await axios.post("/api/report-tag", { + paperId, + reportedFields, + comment, + reporterEmail: email || undefined, + }); + + toast.success("Reported successfully. Thank you, We will work on that."); + + modalSetOpen(false); + setComment(""); + setEmail(""); + setSelectedCategories([]); + setCategoryValues({}); + } catch (err: any) { + console.error(err); + + const msg = + err?.response?.data?.error || + err?.message || + "Failed to submit report."; + + toast.error(msg); + } finally { + setLoading(false); + } + }; return ( @@ -228,12 +243,14 @@ const ReportTagModal = ({ - Report Wrong Tags +
+ Report Wrong Tags +
Help us improve tagging — suggest correct tags and add an optional comment. -
@@ -257,7 +274,7 @@ const ReportTagModal = ({ {selectedCategories.length > 0 && (
{selectedCategories.map((c) => { @@ -279,7 +296,7 @@ const ReportTagModal = ({ className="mb-2 w-full" />
); - } else if (c == "exam") { + } else if (c === "exam") { return (
- + + +
); - } else if (c == "slot") { + } else if (c === "slot") { return (
); } - else if(c=="year"){ + else if(c==="year"){ return (
@@ -382,7 +403,7 @@ const ReportTagModal = ({ setComment(e.target.value)} - placeholder="Short note" + placeholder="eg: Paper quality is not good" />
@@ -398,7 +419,7 @@ const ReportTagModal = ({
-
diff --git a/src/components/pdfViewer.tsx b/src/components/pdfViewer.tsx index 9bd0353..852ec91 100644 --- a/src/components/pdfViewer.tsx +++ b/src/components/pdfViewer.tsx @@ -22,8 +22,7 @@ interface PdfViewerProps { year?: string; } -export default function PdfViewer({ url, name, paperId, subject, exam, slot, year}: PdfViewerProps) { - const [reportOpen, setReportOpen] = useState(false); +export default function PdfViewer({ url, name, paperId, subject, exam, slot, year }: PdfViewerProps) { const [numPages, setNumPages] = useState(); const [pageNumber, setPageNumber] = useState(1); const [scale, setScale] = useState(1); diff --git a/src/lib/utils/redis.ts b/src/lib/utils/redis.ts new file mode 100644 index 0000000..284c9d0 --- /dev/null +++ b/src/lib/utils/redis.ts @@ -0,0 +1,6 @@ +import { Redis } from "@upstash/redis"; + +export const redis = new Redis({ + url: process.env.UPSTASH_REDIS_REST_URL!, + token: process.env.UPSTASH_REDIS_REST_TOKEN!, +}); From 1874f70d516a560f80cf7dca696660ee2c49e995 Mon Sep 17 00:00:00 2001 From: Yogesh Date: Wed, 3 Dec 2025 20:20:50 +0530 Subject: [PATCH 3/3] fix: in reportTag files 1) usecontext instead of propdrilling paper information from pdfviewer till the very child element 2) separate reusable component for Label and input 3)disabled the button if user didn't change anything.instead of throwing an error. --- src/app/api/report-tag/route.ts | 24 ++- src/app/paper/[id]/page.tsx | 16 +- src/components/ReportButton.tsx | 13 +- src/components/ReportTagModal.tsx | 282 ++++++++++++---------------- src/components/pdfViewer.tsx | 29 +-- src/components/ui/LabeledInput.tsx | 39 ++++ src/components/ui/LabeledSelect.tsx | 34 ++++ src/components/ui/field.tsx | 14 ++ src/components/ui/label.tsx | 18 ++ src/context/PaperContext.tsx | 23 +++ 10 files changed, 287 insertions(+), 205 deletions(-) create mode 100644 src/components/ui/LabeledInput.tsx create mode 100644 src/components/ui/LabeledSelect.tsx create mode 100644 src/components/ui/field.tsx create mode 100644 src/components/ui/label.tsx create mode 100644 src/context/PaperContext.tsx diff --git a/src/app/api/report-tag/route.ts b/src/app/api/report-tag/route.ts index 41b9929..3546942 100644 --- a/src/app/api/report-tag/route.ts +++ b/src/app/api/report-tag/route.ts @@ -3,8 +3,12 @@ import { connectToDatabase } from "@/lib/database/mongoose"; import TagReport from "@/db/tagReport"; import { Ratelimit } from "@upstash/ratelimit"; import { redis } from "@/lib/utils/redis"; +import { exams } from "@/components/select_options"; -const ALLOWED_EXAMS = ["CAT-1", "CAT-2", "FAT"]; +interface ReportedFieldInput { + field: string; + value?: string; +} const ALLOWED_FIELDS = ["subject", "courseCode", "exam", "slot", "year"]; const ratelimit = new Ratelimit({ @@ -13,7 +17,7 @@ const ratelimit = new Ratelimit({ analytics: true, }); -function getClientIp(req: any): string { +function getClientIp(req: Request & { ip?: string}): string { return req.ip || "127.0.0.1"; } @@ -49,12 +53,14 @@ export async function POST(req: Request & { ip?: string }) { { status: 429 } ); } - const reportedFields = (body.reportedFields ?? []) - .map((r: any) => ({ - field: String(r.field).trim(), - value: r.value?.trim(), - })) - .filter((r: any) => r.field); + const reportedFields: ReportedFieldInput[] = Array.isArray(body.reportedFields) + ? body.reportedFields + .map((r:Partial) => ({ + field: typeof r.field === "string" ? r.field.trim() : "", + value: typeof r.value === "string" ? r.value.trim() : undefined, + })) + .filter((r:Partial) => r.field) + : []; for (const rf of reportedFields) { if (!ALLOWED_FIELDS.includes(rf.field)) { @@ -64,7 +70,7 @@ export async function POST(req: Request & { ip?: string }) { ); } if (rf.field === "exam" && rf.value) { - if (!ALLOWED_EXAMS.includes(rf.value)) { + if (!exams.some(e => e.toLowerCase() === rf.value?.toLowerCase())) { return NextResponse.json( { error: `Invalid exam value: ${rf.value}` }, { status: 400 } diff --git a/src/app/paper/[id]/page.tsx b/src/app/paper/[id]/page.tsx index e353df0..80c8d02 100644 --- a/src/app/paper/[id]/page.tsx +++ b/src/app/paper/[id]/page.tsx @@ -7,6 +7,7 @@ import { extractBracketContent } from "@/lib/utils/string"; import axios, { type AxiosResponse } from "axios"; import { type Metadata } from "next"; import { redirect } from "next/navigation"; +import { PaperProvider } from "@/context/PaperContext"; export async function generateMetadata({ params, @@ -164,15 +165,20 @@ const PaperPage = async ({ params }: { params: { id: string } }) => {
+ +
diff --git a/src/components/ReportButton.tsx b/src/components/ReportButton.tsx index df3bd5b..80cd7f8 100644 --- a/src/components/ReportButton.tsx +++ b/src/components/ReportButton.tsx @@ -4,18 +4,11 @@ import { useState } from "react"; import { FaFlag } from "react-icons/fa6"; import { Button } from "./ui/button"; import ReportTagModal from "./ReportTagModal"; +import { usePaper } from "@/context/PaperContext"; -export default function ReportButton({ - paperId, subject, exam, slot, year -}: { - paperId: string; - subject?: string; - exam?: string; - slot?: string; - year?: string; -}) { +export default function ReportButton(){ + const { paperId, subject, exam, slot, year } = usePaper(); const [open, setOpen] = useState(false); - return ( <> + -
- -
diff --git a/src/components/pdfViewer.tsx b/src/components/pdfViewer.tsx index 852ec91..dbd7502 100644 --- a/src/components/pdfViewer.tsx +++ b/src/components/pdfViewer.tsx @@ -1,5 +1,7 @@ "use client"; +import "react-pdf/dist/Page/AnnotationLayer.css"; +import "react-pdf/dist/Page/TextLayer.css"; import ReportButton from "./ReportButton"; import { useState, useRef, useCallback, useEffect } from "react"; import { Document, Page, pdfjs } from "react-pdf"; @@ -15,14 +17,9 @@ pdfjs.GlobalWorkerOptions.workerSrc = interface PdfViewerProps { url: string; name: string; - paperId: string; - subject?: string; - exam?: string; - slot?: string; - year?: string; } -export default function PdfViewer({ url, name, paperId, subject, exam, slot, year }: PdfViewerProps) { +export default function PdfViewer({ url, name }: PdfViewerProps) { const [numPages, setNumPages] = useState(); const [pageNumber, setPageNumber] = useState(1); const [scale, setScale] = useState(1); @@ -248,13 +245,7 @@ export default function PdfViewer({ url, name, paperId, subject, exam, slot, yea > {isFullscreen ? : } - + )} @@ -292,8 +283,8 @@ export default function PdfViewer({ url, name, paperId, subject, exam, slot, yea @@ -402,13 +393,7 @@ export default function PdfViewer({ url, name, paperId, subject, exam, slot, yea of {numPages ?? 1} - + )} diff --git a/src/components/ui/LabeledInput.tsx b/src/components/ui/LabeledInput.tsx new file mode 100644 index 0000000..b8c2860 --- /dev/null +++ b/src/components/ui/LabeledInput.tsx @@ -0,0 +1,39 @@ +"use client"; + +import React from "react"; +import { Field } from "@/components/ui/field"; +import { Input } from "@/components/ui/input"; + +interface LabeledInputProps { + label: string; + value: string; + onChange: (v: string) => void; + placeholder?: string; + type?: string; + maxLength?: number; + className?: string; +} + +const LabeledInput = ({ + label, + value, + onChange, + placeholder = "", + type = "text", + maxLength, + className = "", +}: LabeledInputProps) => { + return ( + + onChange(e.target.value)} + placeholder={placeholder} + type={type} + maxLength={maxLength} + /> + + ); +}; + +export default LabeledInput; diff --git a/src/components/ui/LabeledSelect.tsx b/src/components/ui/LabeledSelect.tsx new file mode 100644 index 0000000..0ebd2e8 --- /dev/null +++ b/src/components/ui/LabeledSelect.tsx @@ -0,0 +1,34 @@ +"use client"; + +import React from "react"; +import { Field } from "@/components/ui/field"; +import { Select, SelectTrigger, SelectContent, SelectValue, SelectItem } from "@/components/ui/select"; + +interface LabeledSelectProps { + label: string; + value: string; + onChange: (v: string) => void; + options: string[]; + placeholder?: string; +} + +const LabeledSelect = ({ label, value, onChange, options, placeholder }: LabeledSelectProps) => { + return ( + + + + ); +}; + +export default LabeledSelect; diff --git a/src/components/ui/field.tsx b/src/components/ui/field.tsx new file mode 100644 index 0000000..30e880f --- /dev/null +++ b/src/components/ui/field.tsx @@ -0,0 +1,14 @@ +import { Label } from "@/components/ui/label"; + +export function Field({ label, children, className }: { + label: string; + children: React.ReactNode; + className?: string; +}) { + return ( +
+ + {children} +
+ ); +} diff --git a/src/components/ui/label.tsx b/src/components/ui/label.tsx new file mode 100644 index 0000000..cefba1e --- /dev/null +++ b/src/components/ui/label.tsx @@ -0,0 +1,18 @@ +import * as React from "react"; +import { cn } from "@/lib/utils"; + +export const Label = React.forwardRef< + HTMLLabelElement, + React.LabelHTMLAttributes +>(({ className, ...props }, ref) => ( +