diff --git a/apps/backend/.env.example b/apps/backend/.env.example new file mode 100644 index 0000000..cb78ca3 --- /dev/null +++ b/apps/backend/.env.example @@ -0,0 +1,5 @@ +PORT= +JWT_SECRET= +JWT_REFRESH_SECRET= +EMAIL_USER= +EMAIL_PASS= \ No newline at end of file diff --git a/apps/backend/helpers/auth.ts b/apps/backend/helpers/auth.ts new file mode 100644 index 0000000..12e070e --- /dev/null +++ b/apps/backend/helpers/auth.ts @@ -0,0 +1,91 @@ +import jwt from "jsonwebtoken"; +import nodemailer from "nodemailer"; +import crypto from "crypto"; +import { client } from "db/client"; + +const generateOtp = () => { + return crypto.randomInt(100000, 999999); +}; + +const sendOtp = async (email: string, otp: string) => { + const transporter = nodemailer.createTransport({ + service: "gmail", + auth: { + user: process.env.EMAIL_USER, + pass: process.env.EMAIL_PASS, + }, + }); + + const mailOptions = { + from: `"100xContest" <${process.env.EMAIL_USER}>`, + to: email, + subject: "Email verification code", + + html: ` +
+

Hi there,

+

+ Please use the code below to confirm your email address and continue on 100xContest. + This code will expire in 5 minutes. If you don't think you should be receiving this email, you can safely ignore it. +

+ +
+ ${otp} +
+ +
+ +

+ You received this email because you requested a confirmation code from 100xContest. +

+
+ `, + }; + + await transporter.sendMail(mailOptions); +}; + +const resendOtp = async (email: string) => { + const otp = generateOtp().toString(); + const expiresAt = new Date(Date.now() + 5 * 60 * 1000); + + await client.otpVerification.upsert({ + where: { email }, + update: { otp, expiresAt, attempts: 0 }, + create: { email, otp, expiresAt }, + }); + + await sendOtp(email, otp); +}; + +const generateAccessToken = (user: any) => { + const secret = process.env.JWT_SECRET; + if (!secret) { + throw new Error("JWT_SECRET is not defined in environment variables"); + } + return jwt.sign({ userId: user.id, email: user.email }, secret, { + expiresIn: "2h", + }); +}; + +const generateRefreshToken = (user: any) => { + const refreshSecret = process.env.JWT_REFRESH_SECRET; + if (!refreshSecret) { + throw new Error( + "JWT_REFRESH_SECRET is not defined in environment variables" + ); + } + return jwt.sign({ userId: user.id, email: user.email }, refreshSecret, { + expiresIn: "30d", + }); +}; + +const refreshAccessToken = (refreshToken: string) => { + const decoded = jwt.verify( + refreshToken, + process.env.JWT_REFRESH_SECRET as string + ) as any; + return generateAccessToken({ id: decoded.userId, email: decoded.email }); +}; + +export { generateOtp, generateAccessToken, generateRefreshToken, sendOtp, resendOtp, refreshAccessToken }; diff --git a/apps/backend/helpers/bcrypt.ts b/apps/backend/helpers/bcrypt.ts new file mode 100644 index 0000000..b319cca --- /dev/null +++ b/apps/backend/helpers/bcrypt.ts @@ -0,0 +1,12 @@ +import bcrypt from "bcrypt"; + +const hash = (password: string) => { + const hashed = bcrypt.hash(password, 10); + + return hashed; +}; +const compare = (password: string, hash: string) => { + return bcrypt.compare(password, hash); +}; + +export { hash, compare }; diff --git a/apps/backend/index.ts b/apps/backend/index.ts index 31505fd..79c1d76 100644 --- a/apps/backend/index.ts +++ b/apps/backend/index.ts @@ -1,12 +1,52 @@ - import express from "express"; -import userRouter from "./routes/user" -import contestRouter from "./routes/contest" -import adminRouter from "./routes/admin" +import cors from "cors"; +import cookieParser from "cookie-parser"; +import dotenv from "dotenv"; + +// routes import +import contestRouter from "./routes/contest"; +import userRouter from "./routes/user"; +import adminRouter from "./routes/admin"; + const app = express(); +dotenv.config(); + +const allowedOrigins = ["http://localhost:3000"]; +const corsOptions = { + origin: function (origin: any, callback: any) { + if (!origin) { + return callback(null, true); + } + + if (allowedOrigins.includes(origin)) { + callback(null, true); + } else { + console.error(`Blocked by CORS: ${origin}`); + callback(new Error(`Origin ${origin} not allowed by CORS`)); + } + }, + methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"], + allowedHeaders: ["Content-Type", "Authorization", "X-Requested-With"], + credentials: true, + preflightContinue: false, + optionsSuccessStatus: 204, +}; + +// Middleware +app.use(cors(corsOptions)); +app.use(cookieParser()); +app.use(express.json()); + +// Routes +app.get("/health", (req, res) => { + res.json({ message: "Health Check!" }); +}); +app.use("/api/v1/user", userRouter); +app.use("/api/v1/admin", adminRouter); +app.use("/api/v1/contest", contestRouter); -app.use("/user", userRouter); -app.use("/admin", adminRouter); -app.use("/contest", contestRouter); +const port = process.env.PORT || 4000; -app.listen(process.env.PORT ?? 4000); \ No newline at end of file +app.listen(port, () => { + console.log(`Server is running at http://localhost:${port}`); +}); diff --git a/apps/backend/package.json b/apps/backend/package.json index cd7ff4e..5abae8e 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -9,8 +9,20 @@ "typescript": "^5.0.0" }, "dependencies": { + "@types/bcrypt": "^6.0.0", + "@types/cookie-parser": "^1.4.9", + "@types/cors": "^2.8.17", "@types/express": "^5.0.3", + "@types/jsonwebtoken": "^9.0.10", + "@types/nodemailer": "^7.0.1", + "bcrypt": "^6.0.0", + "cookie-parser": "^1.4.7", + "cors": "^2.8.5", + "db": "*", + "dotenv": "^17.2.1", "express": "^5.1.0", - "db": "*" + "express-rate-limit": "^8.0.1", + "jsonwebtoken": "^9.0.2", + "nodemailer": "^7.0.6" } } \ No newline at end of file diff --git a/apps/backend/routes/admin.ts b/apps/backend/routes/admin.ts index ce6afbb..55f75ee 100644 --- a/apps/backend/routes/admin.ts +++ b/apps/backend/routes/admin.ts @@ -1,15 +1,71 @@ import { Router } from "express"; +import { client } from "db/client"; +import { generateAccessToken, generateRefreshToken } from "../helpers/auth"; const router = Router(); -router.post("/signup", (req, res) => { +// Note for bhayia - no signup needed for admin, seed an email- pass for admin, just login +// router.post("/signup", (req, res) => { +// }) -}) +router.post("/signin", async (req, res) => { + try { + const { email, password } = req.body; -router.post("/signin", (req, res) => { + if (!email || !password) { + return res.status(400).json({ + success: false, + message: "Email and password are required", + }); + } + const admin = await client.user.findFirst({ + where: { + email, + role: "Admin", + }, + }); -}) + if (!admin) { + return res.status(401).json({ + success: false, + message: "Invalid credentials", + }); + } -export default router; \ No newline at end of file + if (admin.password !== password) { + return res.status(401).json({ + success: false, + message: "Invalid credentials", + }); + } + + const accessToken = generateAccessToken(admin); + const refreshToken = generateRefreshToken(admin); + + res.cookie("refreshToken", refreshToken, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "strict", + maxAge: 30 * 24 * 60 * 60 * 1000, + }); + + res.json({ + accessToken, + user: { + id: admin.id, + email: admin.email, + role: admin.role, + }, + }); + } catch (error) { + console.error("Admin signin error:", error); + res.status(500).json({ + success: false, + message: "Internal server error", + }); + } +}); + +export default router; diff --git a/apps/backend/routes/contest.ts b/apps/backend/routes/contest.ts index d23be12..207bb4b 100644 --- a/apps/backend/routes/contest.ts +++ b/apps/backend/routes/contest.ts @@ -1,37 +1,157 @@ import { Router } from "express"; +import { client } from "db/client"; +import { rateLimit } from "express-rate-limit"; const router = Router(); +const limiter = rateLimit({ + windowMs: 15 * 60 * 1000, + max: 20, + message: "Too many submissions, please try again after 15 minutes", +}); // https://api.devforces.com/contest?offset=10&page=20 -router.get("/active", (req, res) => { - const {offset, page} = req.query; +router.get("/active", async (req, res) => { + try { + const { offset = 0, page = 10 } = req.query; + const currentTime = new Date(); -}) + const contests = await client.contest.findMany({ + where: { + startTime: { + lte: currentTime, + }, + }, + skip: parseInt(offset as string), + take: parseInt(page as string), + include: { + contestToChallengeMapping: { + include: { + challenge: true, + }, + }, + }, + }); + res.status(200).json(contests); + } catch (error) { + console.log(error); + res.status(500).json({ error: "Failed to fetch active contests" }); + } +}); -router.get("/finished", (req, res) => { - let {offset, page} = req.query; -}) +router.get("/finished", async (req, res) => { + try { + const { offset = 0, page = 10 } = req.query; + const currentTime = new Date(); + + const contests = await client.contest.findMany({ + where: { + startTime: { + lt: currentTime, + }, + }, + skip: parseInt(offset as string), + take: parseInt(page as string), + include: { + contestToChallengeMapping: { + include: { + challenge: true, + }, + }, + }, + }); + + res.json(contests); + } catch (error) { + res.status(500).json({ error: "Failed to fetch finished contests" }); + } +}); // return all the sub challenges and their start times. -router.get("/:contestId", (req, res) => { - const contestId = req.params.contestId +router.get("/:contestId", async (req, res) => { + try { + const contestId = req.params.contestId; + + const contest = await client.contest.findUnique({ + where: { id: contestId }, + include: { + contestToChallengeMapping: { + include: { + challenge: true, + }, + orderBy: { + index: "asc", + }, + }, + }, + }); + + if (!contest) { + return res.status(404).json({ error: "Contest not found" }); + } + + res.json(contest); + } catch (error) { + res.status(500).json({ error: "Failed to fetch contest" }); + } +}); + +router.get("/:contestId/:challengeId", async (req, res) => { + try { + const { contestId, challengeId } = req.params; + + const challengeMapping = await client.contestToChallengeMapping.findFirst({ + where: { + contestId, + challengeId, + }, + include: { + challenge: true, + contest: true, + }, + }); -}) + if (!challengeMapping) { + return res + .status(404) + .json({ error: "Challenge not found in this contest" }); + } -router.get("/:contestId/:challengeId", (req, res) => { - const contestId = req.params.contestId + res.json(challengeMapping); + } catch (error) { + res.status(500).json({ error: "Failed to fetch challenge" }); + } +}); -}) +router.get("/leaderboard/:contestId", async (req, res) => { + try { + const contestId = req.params.contestId; -router.get("/leaderboard/:contestId", (req, res) => { + const leaderboard = await client.leaderboard.findMany({ + where: { contestId }, + include: { + user: { + select: { + id: true, + email: true, + }, + }, + }, + orderBy: { + rank: "asc", + }, + }); -}) + res.json(leaderboard); + } catch (error) { + res.status(500).json({ error: "Failed to fetch leaderboard" }); + } +}); -router.post("/submit/:challengeId", (req, res) => { - // have rate limitting - // max 20 submissions per problem - // forward the request to GPT - // store the response in sorted set and the DB -}) +router.post("/submit/:challengeId", limiter, async (req, res) => { + // have rate limitting + // max 20 submissions per problem + // forward the request to GPT + // store the response in sorted set and the DB +}); -export default router; \ No newline at end of file +export default router; diff --git a/apps/backend/routes/user.ts b/apps/backend/routes/user.ts index e204b2b..87006b6 100644 --- a/apps/backend/routes/user.ts +++ b/apps/backend/routes/user.ts @@ -1,15 +1,232 @@ import { Router } from "express"; import { client } from "db/client"; +import { + generateAccessToken, + generateOtp, + generateRefreshToken, + refreshAccessToken, + sendOtp, + resendOtp, +} from "../helpers/auth"; +import { hash, compare } from "../helpers/bcrypt"; const router = Router(); -router.post("/signup", (req, res) => { +router.post("/signup", async (req, res) => { + try { + const { email, password } = req.body; -}) + if (!email && !password) { + return res + .status(400) + .json({ message: "Email and password are required!" }); + } + + const exists = await client.user.findFirst({ + where: { email: email.toLowerCase() }, + }); + + if (exists) { + return res.status(409).json({ message: "User already exists!" }); + } + + const otp = generateOtp().toString(); + const expiresAt = new Date(Date.now() + 5 * 60 * 1000); + + await client.otpVerification.upsert({ + where: { email }, + update: { otp, expiresAt, attempts: 0 }, + create: { email, otp, expiresAt }, + }); + + await sendOtp(email, otp); + + res.status(200).json({ + message: "OTP sent successfully", + email, + nextStep: "/verify-otp", + }); + } catch (error) { + console.log(error); + } +}); + +router.post("/verify-otp", async (req, res) => { + try { + const { email, otp, password } = req.body; + + if (!email && !otp && !password) { + return res.status(400).json({ message: "Credentials are required!" }); + } + + const otpRecord = await client.otpVerification.findUnique({ + where: { email }, + }); + + if (!otpRecord) { + return res.status(404).json({ message: "OTP not found or expired" }); + } + + if (otpRecord.expiresAt < new Date()) { + await client.otpVerification.delete({ where: { email } }); + return res.status(410).json({ message: "OTP expired" }); + } + + if (otpRecord.attempts >= 5) { + await client.otpVerification.delete({ where: { email } }); + return res.status(429).json({ message: "Too many failed attempts" }); + } + + if (otpRecord.otp !== otp) { + await client.otpVerification.update({ + where: { email }, + data: { attempts: { increment: 1 } }, + }); + + const remainingAttempts = 5 - (otpRecord.attempts + 1); + return res.status(401).json({ + message: `Invalid OTP. ${remainingAttempts} attempts remaining.`, + }); + } + + const hashedPassword = await hash(password); + + const userData: any = { + email: email.toLowerCase(), + password: hashedPassword, + role: "User", // change -- for admin signups bhayia + }; + + const user = await client.user.create({ + data: userData, + }); + + client.otpVerification.delete({ where: { email } }); + + const accessToken = generateAccessToken(user); + const refreshToken = generateRefreshToken(user); + + res.cookie("refreshToken", refreshToken, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "strict", + maxAge: 30 * 24 * 60 * 60 * 1000, + }); + + res.status(201).json({ + message: "User created successfully", + accessToken, + user: { + id: user.id, + email: user.email, + role: user.role, + }, + }); + } catch (error) { + console.log(error); + } +}); + +router.post("/resend-otp", async (req, res) => { + try { + const { email } = req.body; + + if (!email) { + return res.status(400).json({ message: "Email is required" }); + } + + const existingUser = await client.user.findFirst({ + where: { email: email.toLowerCase() }, + }); + + if (existingUser) { + return res.status(409).json({ message: "User already exists" }); + } + + await resendOtp(email); + + res.status(200).json({ + message: "New OTP sent successfully", + email, + }); + } catch (error) { + console.log(error); + } +}); + +router.post("/signin", async (req, res) => { + try { + const { email, password } = req.body; + if (!email || !password) { + return res + .status(400) + .json({ message: "Email and password are required!" }); + } + + const user = await client.user.findFirst({ + where: { email: email.toLowerCase(), role: 'User' }, + }); + + if (!user || !user.password) { + return res.status(401).json({ message: "Invalid credentials" }); + } + + const isValidPassword = await compare(password, user.password); + if (!isValidPassword) { + return res.status(401).json({ message: "Invalid credentials" }); + } + const accessToken = generateAccessToken(user); + const refreshToken = generateRefreshToken(user); + + res.cookie("refreshToken", refreshToken, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "strict", + maxAge: 30 * 24 * 60 * 60 * 1000, + }); + + res.json({ + accessToken, + user: { + id: user.id, + email: user.email, + role: user.role, + }, + }); + } catch (error) { + console.log(error); + } +}); -router.post("/signin", (req, res) => { +router.post("/signout", async (req, res) => { + const cookieOptions = [ + { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "strict" as const, + path: "/", + }, + ]; + cookieOptions.forEach((options, index) => { + res.clearCookie("refreshToken", options); + }); + + res.status(200).json({ message: "Logged out successfully" }); +}); +// note for bhayia - common for admin and users both +router.post("/refresh", async (req, res) => { + const refreshToken = req.cookies.refreshToken; + if (!refreshToken) { + return res.status(401).json({ message: "Refresh token missing" }); + } + try { + const newAccessToken = refreshAccessToken(refreshToken); + res.status(200).json({ accessToken: newAccessToken }); + } catch (error) { + return res.status(403).json({ message: "Invalid refresh token" }); + } }) -export default router; \ No newline at end of file +export default router; diff --git a/apps/web/.gitignore b/apps/web/.gitignore index f886745..5ef6a52 100644 --- a/apps/web/.gitignore +++ b/apps/web/.gitignore @@ -3,8 +3,12 @@ # dependencies /node_modules /.pnp -.pnp.js -.yarn/install-state.gz +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions # testing /coverage @@ -24,8 +28,9 @@ npm-debug.log* yarn-debug.log* yarn-error.log* +.pnpm-debug.log* -# env files (can opt-in for commiting if needed) +# env files (can opt-in for committing if needed) .env* # vercel diff --git a/apps/web/README.md b/apps/web/README.md deleted file mode 100644 index a98bfa8..0000000 --- a/apps/web/README.md +++ /dev/null @@ -1,36 +0,0 @@ -This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/create-next-app). - -## Getting Started - -First, run the development server: - -```bash -npm run dev -# or -yarn dev -# or -pnpm dev -# or -bun dev -``` - -Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. - -You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. - -This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load Inter, a custom Google Font. - -## Learn More - -To learn more about Next.js, take a look at the following resources: - -- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. -- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. - -You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! - -## Deploy on Vercel - -The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. - -Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. diff --git a/apps/web/app/admin/page.tsx b/apps/web/app/admin/page.tsx new file mode 100644 index 0000000..04c51d9 --- /dev/null +++ b/apps/web/app/admin/page.tsx @@ -0,0 +1,207 @@ +"use client" + +import type React from "react" + +import { useState } from "react" +import { Button } from "@/components/ui/button" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Textarea } from "@/components/ui/textarea" +import { Switch } from "@/components/ui/switch" +// import { useToast } from "@/hooks/use-toast" + +type SubChallenge = { title: string; points: number; live: boolean } + +export default function AdminPage() { + // const { toast } = useToast() + const [name, setName] = useState("") + const [description, setDescription] = useState("") + const [live, setLive] = useState(false) + const [subs, setSubs] = useState([{ title: "Warm-up task", points: 100, live: true }]) + const [scheduled, setScheduled] = useState(false) + const [startAt, setStartAt] = useState("") + const [endAt, setEndAt] = useState("") + + const addSub = () => setSubs((s) => [...s, { title: "", points: 100, live: false }]) + const removeSub = (idx: number) => setSubs((s) => s.filter((_, i) => i !== idx)) + const updateSub = (idx: number, patch: Partial) => + setSubs((s) => s.map((item, i) => (i === idx ? { ...item, ...patch } : item))) + + const submit = (e: React.FormEvent) => { + e.preventDefault() + // toast({ + // title: "Contest created (mock)", + // description: `“${name || "Untitled"}” with ${subs.length} sub-challenge(s). ${ + // scheduled && startAt ? `Starts at ${new Date(startAt).toLocaleString()}.` : "" + // }`, + // }) + setName("") + setDescription("") + setLive(false) + setSubs([{ title: "Warm-up task", points: 100, live: true }]) + setScheduled(false) + setStartAt("") + setEndAt("") + } + + return ( +
+
+

Admin

+

+ Create a contest and add sub-challenges. This is a frontend-only mock. +

+
+ + + + New Contest + Define the contest metadata and add sub-challenges. + + +
+
+ + setName(e.target.value)} + placeholder="100x Sprint" + required + className="transition-shadow duration-150 focus-visible:shadow-sm" + /> +

Keep it short and recognizable.

+
+ +
+ +