diff --git a/package-lock.json b/package-lock.json index adecf34..b4dda95 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,6 +27,7 @@ "next": "^14.2.4", "next-axiom": "^0.18.1", "patch-package": "^8.0.0", + "rate-limiter-flexible": "^5.0.3", "react": "18.2.0", "react-collapsed": "^4.0.4", "react-dom": "18.2.0", @@ -6073,6 +6074,12 @@ ], "license": "MIT" }, + "node_modules/rate-limiter-flexible": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/rate-limiter-flexible/-/rate-limiter-flexible-5.0.3.tgz", + "integrity": "sha512-lWx2y8NBVlTOLPyqs+6y7dxfEpT6YFqKy3MzWbCy95sTTOhOuxufP2QvRyOHpfXpB9OUJPbVLybw3z3AVAS5fA==", + "license": "ISC" + }, "node_modules/react": { "version": "18.2.0", "resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz", diff --git a/package.json b/package.json index b42b3e5..6812bb2 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "next": "^14.2.4", "next-axiom": "^0.18.1", "patch-package": "^8.0.0", + "rate-limiter-flexible": "^5.0.3", "react": "18.2.0", "react-collapsed": "^4.0.4", "react-dom": "18.2.0", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index c81a90e..74d3edd 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -14,7 +14,7 @@ datasource db { model Question { id Int @id @default(autoincrement()) - questionNum Int @unique + num Int @unique title String @default("") @db.VarChar(128) startsAt DateTime endsAt DateTime @@ -26,7 +26,7 @@ model Question { startEvents StartEvent[] questionStats QuestionStats[] - @@index([id]) + @@index([num]) @@index([startsAt, endsAt]) } @@ -94,3 +94,9 @@ model UserStats { @@index([userId]) } + +model RateLimiter { + key String @id + points Int + expire DateTime? +} diff --git a/src/components/constants.tsx b/src/components/constants.tsx index 21a2d49..2d2ed37 100644 --- a/src/components/constants.tsx +++ b/src/components/constants.tsx @@ -22,3 +22,11 @@ export const skipBatchOpts = { } } } + +export const statusColors = { + ERROR: "text-rose-700", + TIMEOUT: "text-rose-700", + INCORRECT: "text-yellow-500", + CORRECT: "text-emerald-600", + UNKNOWN: "text-slate-500" +}; diff --git a/src/components/layout.tsx b/src/components/layout.tsx index 5550a06..26d92e4 100644 --- a/src/components/layout.tsx +++ b/src/components/layout.tsx @@ -1,15 +1,15 @@ -import { SignInButton, SignedIn, SignedOut, UserButton, useAuth } from "@clerk/nextjs"; +import { SignInButton, SignedIn, SignedOut, UserButton } from "@clerk/nextjs"; import Head from "next/head"; import Link from "next/link"; -import { type PropsWithChildren } from "react"; +import { createContext, type PropsWithChildren } from "react"; import { api, getBaseUrl } from "~/utils/api"; -import { LinkedInLogoIcon } from '@radix-ui/react-icons'; import { useTimer } from "react-timer-hook"; import toast from "react-hot-toast"; import { useRouter } from "next/navigation"; import { noRefreshOpts, skipBatchOpts } from "./constants"; import { GlobalLeaderboard, PersonalStats } from "./sidebar"; import { MetaHeadEmbed } from "@phntms/react-share"; +import { type PublicQuestionInfo } from "~/server/helpers/filter"; const Account = () => { return ( @@ -45,7 +45,7 @@ export const GreenSignInButton = () => { } const QuestionCountDown = ({ expTimestamp }: { expTimestamp: Date }) => { - const ctx = api.useContext() + const ctx = api.useUtils() const router = useRouter() const displayNewQuestionAvailable = () => { @@ -94,8 +94,9 @@ const QuestionCountDown = ({ expTimestamp }: { expTimestamp: Date }) => { return Next question in {`${remainingTime.hours}h ${remainingTime.minutes}m ${remainingTime.seconds}s`}. } +export const GlobalStatusContext = createContext(undefined) + export const PageLayout = (props: PropsWithChildren) => { - const { isSignedIn } = useAuth() const { data: globalStatus } = api.status.global.useQuery(undefined, { ...noRefreshOpts, ...skipBatchOpts @@ -106,7 +107,7 @@ export const PageLayout = (props: PropsWithChildren) => { }) return ( - <> + <>{meta}} @@ -137,16 +138,12 @@ export const PageLayout = (props: PropsWithChildren) => {
CodeReal
- {isSignedIn && -
- Past Submissions -
- } +
+ Past Questions +
-
- -
+
@@ -167,35 +164,29 @@ export const PageLayout = (props: PropsWithChildren) => {
-
-
+
+
{globalStatus && globalStats ? ( <> {globalStats.numAnswered} people have solved today's question. - + ) : 'Loading...'}
-
-
-

Copyright © {new Date().getFullYear()} Noah Kurrack. All rights reserved.

-
-
-
- - - -
+
+
+

Copyright © {new Date().getFullYear()} Noah Kurrack. All rights reserved.

- +
) } diff --git a/src/components/question.tsx b/src/components/question.tsx index 64236eb..d02e8b1 100644 --- a/src/components/question.tsx +++ b/src/components/question.tsx @@ -4,13 +4,16 @@ import { useStopwatch } from "react-timer-hook" import { api } from "~/utils/api" import { noRefreshOpts, codeEditorExtensions, codeEditorTheme } from "./constants" import { SubmissionDisplay } from "./submission" -import { type PersonalStatusData } from "~/server/api/routers/status" import toast from "react-hot-toast" +import { useAuth } from "@clerk/nextjs" +import { GreenSignInButton } from "./layout" +import { LoadingSpinner } from "./loading" +import { type PublicQuestionInfo } from "~/server/helpers/filter" const Question = ({ questionId }: { questionId: number }) => { const [isSubmitTimeout, setIsSubmitTimeout] = useState(false) - const { data, isLoading, isError } = api.question.get.useQuery({ id: questionId }, noRefreshOpts) - const { mutate: submitQuestion } = api.submission.submit.useMutation({ + const { data, isLoading, isError } = api.question.getPrivate.useQuery({ id: questionId }, noRefreshOpts) + const { mutate: submitQuestion, isLoading: isSubmitLoading } = api.submission.submit.useMutation({ onSuccess: (data) => { if (data.error) { toast.error(`Execution error: ${data.execResult.errorMessage ?? 'unknown'}`, { @@ -19,7 +22,8 @@ const Question = ({ questionId }: { questionId: number }) => { return } toast.success('Your submission was recorded!') - void utils.status.personal.invalidate() + void utils.status.question.invalidate() + void utils.question.getInfinite.invalidate() void utils.submission.invalidate(undefined, { type: 'all' // refresh queries on other pages }) @@ -39,18 +43,17 @@ const Question = ({ questionId }: { questionId: number }) => { if (isError) return

Question could not be loaded. Please refresh the page.

- const initialValue = data.funcSignature + ' {\n \n}' + const initialValue = data.signature + ' {\n \n}' if (code == "") setCode(initialValue) return (
-

Question #{data.id}: {data.title}

-

{data.questionDescription}

+

{data.description}

{ @@ -90,48 +93,77 @@ const ElapsedTimeCounter = ({ startTime }: { startTime: Date }) => { ) } -export const QuestionHandler = (props: PersonalStatusData) => { +export const QuestionHandler = (questionInfo: PublicQuestionInfo) => { + const { isSignedIn } = useAuth() + + if (!isSignedIn) { + return ( +
+

Question #{questionInfo.questionNum}: {questionInfo.questionTitle}

+

Sign in to get started!

+ +
+ ) + } + + return +} + +const QuestionSignedIn = (questionInfo: PublicQuestionInfo) => { + const { + data: questionStatus, + isLoading: isQuestionStatusLoading, + isError: isQuestionStatusError + } = api.status.question.useQuery({ id: questionInfo.questionId }, noRefreshOpts) const utils = api.useUtils() const { mutate: startQuestion, isLoading: isQuestionLoading } = api.question.start.useMutation({ onSuccess: () => { - void utils.status.personal.invalidate() + void utils.status.question.invalidate() }, onError: (error) => { toast.error(`An error occured: ${error.message}`) } }) - if (props.isCompleted) { + if (isQuestionStatusError) return

Error

+ + if (isQuestionStatusLoading) return
+ + if (questionStatus.isCompleted) { return ( - <> -

You've already completed today's question!

- {props.submissionId - ? +
+

Question #{questionInfo.questionNum}: {questionInfo.questionTitle}

+

You've already completed this question!

+ {questionStatus.submissionId + ? :

There was an error retrieving your submission.

} - +
) } - if (props.isStarted) { + if (questionStatus.isStarted) { return (
-

Today's Question

- - {props.startTime &&

Elapsed time:

} +

Question #{questionInfo.questionNum}: {questionInfo.questionTitle}

+ + {questionStatus.startTime &&

Elapsed time:

}
) } return ( -
-

Click button to start today's question (#{props.questionId})!

- -
+ <> +
+

Question #{questionInfo.questionNum}: {questionInfo.questionTitle}

+

Click the button to start this question:

+ +
+ ) } diff --git a/src/components/submission.tsx b/src/components/submission.tsx index 7bcbb6a..fd69c66 100644 --- a/src/components/submission.tsx +++ b/src/components/submission.tsx @@ -1,36 +1,18 @@ import ReactCodeMirror from "@uiw/react-codemirror"; import { api, type RouterOutputs } from "~/utils/api"; -import { codeEditorExtensions, codeEditorTheme, noRefreshOpts } from "./constants"; -import { useCollapse } from "react-collapsed"; -import { ChevronDownIcon, ChevronRightIcon } from "@radix-ui/react-icons"; +import { codeEditorExtensions, codeEditorTheme, noRefreshOpts, statusColors } from "./constants"; import { LoadingSpinner } from "./loading"; type SubmissionItem = RouterOutputs['submission']['getInfinite']['submissions'][number] -export const Submission = ({ data, solo: isSolo }: { data: SubmissionItem, solo: boolean }) => { - // eslint-disable-next-line @typescript-eslint/unbound-method - const { getCollapseProps, getToggleProps, isExpanded } = useCollapse({ - defaultExpanded: isSolo - }) - - const statusColors = { - ERROR: "text-rose-700", - TIMEOUT: "text-rose-700", - INCORRECT: "text-yellow-500", - CORRECT: "text-emerald-600", - UNKNOWN: "text-slate-500" - }; +export const Submission = ({ data }: { data: SubmissionItem }) => { const statusColor = statusColors[data.runResult] || ""; return ( -
-
-

Question #{data.questionId} Submission ({data.runResult})

- {!isSolo && -
- {isExpanded ? : } -
} + <> +
+

Result: {data.runResult}

-
+
{data.errorMessage &&

Error: "{data.errorMessage}"

@@ -73,7 +55,7 @@ export const Submission = ({ data, solo: isSolo }: { data: SubmissionItem, solo: /> {/* improvement: add share score button */}
-
+ ) } @@ -84,7 +66,7 @@ export const SubmissionDisplay = ({ id }: { id: number }) => { if (isError) return

There was an error retrieving your submission.

- return + return } const convertSeconds = (seconds: number) => { diff --git a/src/pages/index.tsx b/src/pages/index.tsx index ef97603..b8acb00 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -1,9 +1,6 @@ -import { api } from "~/utils/api"; -import { SignedIn, SignedOut } from "@clerk/nextjs"; -import { LoadingSpinner } from "~/components/loading"; +import { useContext } from "react"; +import { GlobalStatusContext } from "~/components/layout"; import { QuestionHandler } from "~/components/question"; -import { noRefreshOpts } from "~/components/constants"; -import { GreenSignInButton } from "~/components/layout"; const Description = () => { return ( @@ -22,36 +19,18 @@ const Description = () => { ) } -const SignedInHome = () => { - const { - data: personalStatusData, - isLoading: isPersonalStatusLoading, - isError: isPersonalStatusError - } = api.status.personal.useQuery(undefined, noRefreshOpts) - - if (isPersonalStatusError) return

Could not connect to backend service. Please refresh the page.

- - if (isPersonalStatusLoading) return
- - // improvement: use suspense boundary (?) - return -} export default function HomePage() { + const globalStatus = useContext(GlobalStatusContext) + + if (!globalStatus) return (

Could not connect to backend service. Please refresh the page.

) return (

- - - - -
-

Sign in to get started!

- -
-
+

Today's Question

+
- ); + ) } diff --git a/src/pages/past-questions.tsx b/src/pages/past-questions.tsx new file mode 100644 index 0000000..6d4ed0f --- /dev/null +++ b/src/pages/past-questions.tsx @@ -0,0 +1,90 @@ +import { ChevronRightIcon } from "@radix-ui/react-icons"; +import dayjs from "dayjs"; +import Link from "next/link"; +import React from "react"; +import { noRefreshOpts, statusColors } from "~/components/constants"; +import { LoadingSpinner } from "~/components/loading"; +import { api } from "~/utils/api"; + +const QuestionList = () => { + const { + data, + error, + isLoading, + fetchNextPage, + hasNextPage, + isFetching, + isFetchingNextPage, + } = api.question.getInfinite.useInfiniteQuery({ + limit: 2 + }, { + getNextPageParam: (lastPage, _pages) => lastPage.nextCursor, + ...noRefreshOpts + }) + + if (error) return

Could not load past questions.

+ + if (isLoading) return
+ + return ( + <> + {data.pages.map((group, i) => ( + + {group.questions.map((question, i) => { + const submission = question.submissions[0] + const statusColor = submission ? statusColors[submission.runResult] : statusColors.UNKNOWN + return ( + +
+
+

+ Question #{question.num}: {question.title} + {dayjs(question.startsAt).format('ddd MMM DD YYYY')} +

+
+
+ ({submission?.runResult ?? "INCOMPLETE"}) +
+
+ +
+
+
+
+ + ) + } + )} +
+ ))} + +
+ {isFetchingNextPage || isFetching + ? + : hasNextPage + ? + + : null} +
+ + ) +} + +export default function PastQuestionsPage() { + return ( + <> +
+

Past Questions

+ {/*

Browse all previous questions.

*/} +
+
+ + + ) +} diff --git a/src/pages/past-submissions.tsx b/src/pages/past-submissions.tsx deleted file mode 100644 index 6dc2da9..0000000 --- a/src/pages/past-submissions.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import React from "react"; -import { noRefreshOpts } from "~/components/constants"; -import { LoadingSpinner } from "~/components/loading"; -import { Submission } from "~/components/submission"; -import { api } from "~/utils/api"; - -const SubmissionsList = () => { - const { - data, - error, - isLoading, - fetchNextPage, - hasNextPage, - isFetching, - isFetchingNextPage, - } = api.submission.getInfinite.useInfiniteQuery({ - limit: 2 - }, { - getNextPageParam: (lastPage, _pages) => lastPage.nextCursor, - ...noRefreshOpts - }) - - if (error) return

Could not load past submissions.

- - if (isLoading) return
- - return ( - <> - {data.pages.map((group, i) => ( - - {group.submissions.map((submission, i) => ( -
- -
-
- ))} -
- ))} - -
- {isFetchingNextPage || isFetching - ? - : hasNextPage - ? - - - : null} -
- - - ) -} - -export default function PastQuestionsPage() { - return ( - <> -
-

Past Submissions

-

All of your previously submitted solutions are listed here.

-
-
- - - ) -} diff --git a/src/pages/question/[num].tsx b/src/pages/question/[num].tsx new file mode 100644 index 0000000..1932b46 --- /dev/null +++ b/src/pages/question/[num].tsx @@ -0,0 +1,23 @@ +import { useRouter } from "next/router"; +import { LoadingSpinner } from "~/components/loading"; +import { QuestionHandler } from "~/components/question"; +import { api } from "~/utils/api"; + + +export default function QuestionPage() { + const router = useRouter() + + if (!router.query.num || typeof router.query.num !== "string") { + return <> + } + + const questionNum = parseInt(router.query.num) + const { data, isLoading, isError } = api.question.getPublic.useQuery({ num: questionNum }) + + if (isError) return

Error loading question

+ if (isLoading) return
+ + return ( + + ) +} diff --git a/src/server/api/routers/question.ts b/src/server/api/routers/question.ts index e7a71f6..aba2e33 100644 --- a/src/server/api/routers/question.ts +++ b/src/server/api/routers/question.ts @@ -1,8 +1,8 @@ import { type PrismaClient } from "@prisma/client"; import { TRPCError } from "@trpc/server"; import { z } from "zod"; -import { createTRPCRouter, privateProcedure } from "~/server/api/trpc"; -import { filterQuestionForClient } from "~/server/helpers/filter"; +import { createTRPCRouter, privateProcedure, publicProcedure } from "~/server/api/trpc"; +import { filterQuestionForClientPrivate, filterQuestionForClientPublic } from "~/server/helpers/filter"; export const getQuestionById = async (db: PrismaClient, qid: number) => { const question = await db.question.findFirst({ @@ -16,21 +16,43 @@ export const getQuestionById = async (db: PrismaClient, qid: number) => { return question } +export const getQuestionByNum = async (db: PrismaClient, num: number) => { + const question = await db.question.findFirst({ + where: { + num: num + } + }) + + if (question === null) throw new TRPCError({ code: "NOT_FOUND" }) + + return question +} + export const questionRouter = createTRPCRouter({ - get: privateProcedure + getPublic: publicProcedure + .input(z.object({ num: z.number() })) + .query(async ({ ctx, input }) => { + const latestQuestion = await getLatestQuestion(ctx.db) + const question = await getQuestionByNum(ctx.db, input.num) + if (question.num > latestQuestion.num) + throw new TRPCError({ code: "BAD_REQUEST" }) + return filterQuestionForClientPublic(question) + }), + getPrivate: privateProcedure .input(z.object({ id: z.number() })) .query(async ({ ctx, input }) => { - const currentQuestion = await getCurrentQuestion(ctx.db) - if (input.id > currentQuestion.id) - throw new TRPCError({ code: "NOT_FOUND" }) + const latestQuestion = await getLatestQuestion(ctx.db) const question = await getQuestionById(ctx.db, input.id) - return filterQuestionForClient(question) + if (question.num > latestQuestion.num) + throw new TRPCError({ code: "BAD_REQUEST" }) + return filterQuestionForClientPrivate(question) }), start: privateProcedure .input(z.object({ questionId: z.number() })) .mutation(async ({ ctx, input }) => { - const currentQuestion = await getCurrentQuestion(ctx.db) - if (input.questionId > currentQuestion.id) + const latestQuestion = await getLatestQuestion(ctx.db) + const question = await getQuestionById(ctx.db, input.questionId) + if (question.num > latestQuestion.num) throw new TRPCError({ code: "NOT_FOUND" }) if (await isQuestionAlreadyStartedByUser(ctx.db, ctx.userId, input.questionId)) @@ -53,7 +75,48 @@ export const questionRouter = createTRPCRouter({ console.error("submissionRouter/submit", e) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" }) } - }) + }), + getInfinite: publicProcedure + .input( + z.object({ + limit: z.number().min(1).max(20).nullish(), + cursor: z.number().nullish() + }), + ) + .query(async ({ ctx, input }) => { + const limit = input.limit ?? 10 + const { cursor } = input + const questions = await ctx.db.question.findMany({ + take: limit + 1, // take an extra at end to use as next cursor + select: { + id: true, + num: true, + title: true, + startsAt: true, + submissions: { + where: { + authorId: ctx.userId ?? "" // ~should~ be no submissions with empty author + }, + select: { + runResult: true + } + } + }, + cursor: cursor ? { id: cursor } : undefined, + orderBy: { + num: 'desc', + }, + }) + let nextCursor: typeof cursor | undefined = undefined + if (questions.length > limit) { + const nextItem = questions.pop() + nextCursor = nextItem!.id + } + return { + questions, + nextCursor, + }; + }), }); const isQuestionAlreadyStartedByUser = async (db: PrismaClient, userId: string, questionId: number) => { @@ -71,7 +134,7 @@ const isQuestionAlreadyStartedByUser = async (db: PrismaClient, userId: string, // Otherwise, this function should only be used to validate that future questions are not being accessed/referenced // All other procs that need a question id must get the id from the client // This allows the user to start/submit previous questions at any time -export const getCurrentQuestion = async (db: PrismaClient) => { +export const getLatestQuestion = async (db: PrismaClient) => { const question = await db.question.findFirst({ where: { startsAt: { diff --git a/src/server/api/routers/statistics.ts b/src/server/api/routers/statistics.ts index ea16079..d3a3d63 100644 --- a/src/server/api/routers/statistics.ts +++ b/src/server/api/routers/statistics.ts @@ -2,20 +2,19 @@ import { type Submission, type PrismaClient, type Prisma } from "@prisma/client" import { createTRPCRouter, privateProcedure, publicProcedure } from "~/server/api/trpc"; import { withTransaction, type TransactionPrismaClient } from "~/server/helpers/transaction"; import { getUserById } from "./user"; -import { getCurrentQuestion } from "./question"; +import { getLatestQuestion } from "./question"; export const statisticsRouter = createTRPCRouter({ global: publicProcedure .query(async ({ ctx }) => { - const currentQuestion = await getCurrentQuestion(ctx.db) + const latestQuestion = await getLatestQuestion(ctx.db) const questionStats = await ctx.db.questionStats.findFirst({ where: { - questionId: currentQuestion.id + questionId: latestQuestion.id } }) return { - questionId: currentQuestion.id, numAnswered: questionStats?.numSubmissions ?? 0, topFive: questionStats?.top5Scores as LeaderboardEntry[] ?? [], lastUpdated: new Date() diff --git a/src/server/api/routers/status.ts b/src/server/api/routers/status.ts index 35af3e7..b1c34be 100644 --- a/src/server/api/routers/status.ts +++ b/src/server/api/routers/status.ts @@ -1,7 +1,10 @@ import { createTRPCRouter, privateProcedure, publicProcedure } from "~/server/api/trpc"; -import { getCurrentQuestion } from "./question"; +import { getLatestQuestion, getQuestionById } from "./question"; +import { z } from "zod"; +import { TRPCError } from "@trpc/server"; +import { filterQuestionForClientPublic } from "~/server/helpers/filter"; -export type PersonalStatusData = { +export type QuestionStatusData = { isStarted: boolean, isCompleted: boolean, questionId: number, @@ -12,19 +15,21 @@ export type PersonalStatusData = { export const statusRouter = createTRPCRouter({ global: publicProcedure .query(async ({ ctx }) => { - const currentQuestion = await getCurrentQuestion(ctx.db) - return { - questionExpiration: currentQuestion.endsAt - } + const latestQuestion = await getLatestQuestion(ctx.db) + return filterQuestionForClientPublic(latestQuestion) }), - personal: privateProcedure - .query(async ({ ctx }): Promise => { - const currentQuestion = await getCurrentQuestion(ctx.db) + question: privateProcedure + .input(z.object({ id: z.number() })) + .query(async ({ ctx, input }): Promise => { + const latestQuestion = await getLatestQuestion(ctx.db) + const question = await getQuestionById(ctx.db, input.id) + if (question.num > latestQuestion.num) + throw new TRPCError({ code: "BAD_REQUEST" }) const startEvent = await ctx.db.startEvent.findFirst({ where: { authorId: ctx.userId, - questionId: currentQuestion.id + questionId: question.id } }) @@ -32,16 +37,16 @@ export const statusRouter = createTRPCRouter({ return { isStarted: false, isCompleted: false, - questionId: currentQuestion.id, + questionId: question.id, submissionId: null, startTime: null - } satisfies PersonalStatusData + } satisfies QuestionStatusData } const submission = await ctx.db.submission.findFirst({ where: { authorId: ctx.userId, - questionId: currentQuestion.id + questionId: question.id }, select: { id: true @@ -53,7 +58,7 @@ export const statusRouter = createTRPCRouter({ startTime: startEvent.createdAt, isCompleted: submission != null, submissionId: submission?.id ?? null, - questionId: currentQuestion.id - } satisfies PersonalStatusData + questionId: question.id + } satisfies QuestionStatusData }) }); diff --git a/src/server/api/routers/submission.ts b/src/server/api/routers/submission.ts index d573ae9..6152594 100644 --- a/src/server/api/routers/submission.ts +++ b/src/server/api/routers/submission.ts @@ -5,6 +5,7 @@ import { type CodeExecutionResult, executeCode } from "~/server/executeCode"; import { getQuestionById } from "./question"; import { type Question, type PrismaClient, SubmissionResult } from "@prisma/client"; import { updateQuestionStats, updateUserStats } from "./statistics"; +import { rateLimit } from "~/server/rateLimiter"; export const submissionRouter = createTRPCRouter({ getById: privateProcedure @@ -25,6 +26,10 @@ export const submissionRouter = createTRPCRouter({ questionId: z.number() })) .mutation(async ({ ctx, input }) => { + const rateLimitResp = await rateLimit(ctx.userId) + if (rateLimitResp.remainingPoints === 0) + throw new TRPCError({ code: "TOO_MANY_REQUESTS", message: `Too many requests. Retry in ${Math.round(rateLimitResp.msBeforeNext / 1000)} seconds.` }) + if (await isQuestionAlreadySubmittedByUser(ctx.db, ctx.userId, input.questionId)) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "You already submitted this question!" }) diff --git a/src/server/db.ts b/src/server/db.ts index 8854ba4..a58d604 100644 --- a/src/server/db.ts +++ b/src/server/db.ts @@ -4,7 +4,7 @@ import { log as axiom } from "next-axiom"; import { env } from "~/env.mjs"; const prismaClientSingleton = () => { - return new PrismaClient({ + const client = new PrismaClient({ log: [ { level: 'query', emit: 'event' }, { level: 'info', emit: 'event' }, @@ -12,6 +12,40 @@ const prismaClientSingleton = () => { { level: 'error', emit: 'event' } ] }); + + const queryBlockList = ["BEGIN", "COMMIT", "SELECT 1"] + + client.$on('query', e => { + if (env.NODE_ENV === "production") { + if (!queryBlockList.some(blockedQuery => e.query.includes(blockedQuery))) { + axiom.info("query", { ...e }); + } + } else { + console.log(e) + } + }) + + client.$on('error', e => { + if (env.NODE_ENV === "production") { + axiom.error("prisma error", { ...e }) + } else { + console.log(e) + } + }) + + client.$on('info', e => { + if (env.NODE_ENV !== 'production') { + console.log(e) + } + }) + + client.$on('warn', e => { + if (env.NODE_ENV !== 'production') { + console.log(e) + } + }) + + return client } declare const globalThis: { @@ -21,27 +55,3 @@ declare const globalThis: { export const db = globalThis.prismaGlobal ?? prismaClientSingleton() if (env.NODE_ENV === 'development') globalThis.prismaGlobal = db - -db.$on('query', e => { - if (env.NODE_ENV !== 'development') { - axiom.info("query", { ...e }) - } else { - console.log(e) - } -}) - -db.$on('info', e => { - if (env.NODE_ENV !== 'production') { - console.log(e) - } -}) - -db.$on('warn', e => { - if (env.NODE_ENV !== 'production') { - console.log(e) - } -}) - -db.$on('error', e => { - console.log(e) -}) diff --git a/src/server/helpers/filter.ts b/src/server/helpers/filter.ts index d4a63a2..e8bd7cc 100644 --- a/src/server/helpers/filter.ts +++ b/src/server/helpers/filter.ts @@ -10,11 +10,25 @@ export const filterUserForClient = (user: User) => { }; }; -export const filterQuestionForClient = (question: Question) => { +export type PublicQuestionInfo = { + questionId: number + questionNum: number + questionTitle: string + questionExp: Date +} + +export const filterQuestionForClientPublic = (question: Question) => { + return { + questionId: question.id, + questionNum: question.num, + questionTitle: question.title, + questionExp: question.endsAt + } as PublicQuestionInfo +} + +export const filterQuestionForClientPrivate = (question: Question) => { return { - id: question.id, - title: question.title, - questionDescription: question.description, - funcSignature: question.funcSig, + description: question.description, + signature: question.funcSig, } } diff --git a/src/server/rateLimiter.ts b/src/server/rateLimiter.ts new file mode 100644 index 0000000..c19fe68 --- /dev/null +++ b/src/server/rateLimiter.ts @@ -0,0 +1,18 @@ +import { TRPCError } from "@trpc/server"; +import { RateLimiterPrisma } from "rate-limiter-flexible" + +import { db } from "~/server/db"; + +const rateLimiter = new RateLimiterPrisma({ + storeClient: db, + tableName: "RateLimiter", + points: 4, + duration: 60, +}) + +export async function rateLimit(userId: string) { + return await rateLimiter.penalty(userId, 1).catch(err => { + console.log("Rate limiter error: " + err) + throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Rate limiter error" }) + }) +}