diff --git a/app/api/collections/[cid]/problems/route.ts b/app/api/collections/[cid]/problems/route.ts new file mode 100644 index 0000000..99f2c3f --- /dev/null +++ b/app/api/collections/[cid]/problems/route.ts @@ -0,0 +1,97 @@ +import prisma from "@/lib/prisma"; +import { NextRequest } from "next/server"; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ cid: string }> }, +) { + const { cid } = await params; + + // Extract and validate Bearer token + const authHeader = request.headers.get("Authorization"); + if (!authHeader || !authHeader.startsWith("Bearer ")) { + return Response.json( + { error: "Missing or invalid Authorization header. Use: Bearer " }, + { status: 401 }, + ); + } + + const token = authHeader.slice(7); + if (!token) { + return Response.json({ error: "Empty token" }, { status: 401 }); + } + + // Look up token and verify collection ownership + const apiToken = await prisma.apiToken.findUnique({ + where: { token }, + include: { + collection: { + select: { + id: true, + cid: true, + name: true, + }, + }, + }, + }); + + if (!apiToken) { + return Response.json({ error: "Invalid API token" }, { status: 401 }); + } + + if (apiToken.collection.cid !== cid) { + return Response.json( + { error: "Token not authorized for this collection" }, + { status: 403 }, + ); + } + + // Fetch problems with expanded relations + const problems = await prisma.problem.findMany({ + where: { + collectionId: apiToken.collection.id, + isArchived: false, + }, + orderBy: { createdAt: "desc" }, + select: { + id: true, + pid: true, + title: true, + statement: true, + answer: true, + subject: true, + difficulty: true, + source: true, + isArchived: true, + createdAt: true, + authors: { + select: { + id: true, + displayName: true, + country: true, + }, + }, + solutions: { + select: { + id: true, + text: true, + summary: true, + authors: { + select: { + id: true, + displayName: true, + }, + }, + }, + }, + }, + }); + + return Response.json({ + collection: { + cid: apiToken.collection.cid, + name: apiToken.collection.name, + }, + problems, + }); +} diff --git a/prisma/migrations/20260118211256_add_api_tokens/migration.sql b/prisma/migrations/20260118211256_add_api_tokens/migration.sql new file mode 100644 index 0000000..1752f10 --- /dev/null +++ b/prisma/migrations/20260118211256_add_api_tokens/migration.sql @@ -0,0 +1,23 @@ +-- CreateTable +CREATE TABLE "ApiToken" ( + "id" SERIAL NOT NULL, + "token" TEXT NOT NULL, + "name" TEXT NOT NULL, + "collectionId" INTEGER NOT NULL, + "createdByUserId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "ApiToken_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "ApiToken_token_key" ON "ApiToken"("token"); + +-- CreateIndex +CREATE INDEX "ApiToken_token_idx" ON "ApiToken"("token"); + +-- AddForeignKey +ALTER TABLE "ApiToken" ADD CONSTRAINT "ApiToken_collectionId_fkey" FOREIGN KEY ("collectionId") REFERENCES "Collection"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ApiToken" ADD CONSTRAINT "ApiToken_createdByUserId_fkey" FOREIGN KEY ("createdByUserId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 723582b..f836e39 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -22,6 +22,7 @@ model User { comments Comment[] problemLikes ProblemLike[] solveAttempts SolveAttempt[] + apiTokens ApiToken[] } model Account { @@ -136,6 +137,7 @@ model Collection { requireDifficulty Boolean @default(true) requireTestsolve Boolean @default(false) // whether users must testsolve a problem to view it createdAt DateTime @default(now()) + apiTokens ApiToken[] } enum AccessLevel { @@ -229,4 +231,17 @@ model SolveAttempt { gaveUp Boolean @default(false) @@id([userId, problemId]) +} + +model ApiToken { + id Int @id @default(autoincrement()) + token String @unique + name String + collection Collection @relation(fields: [collectionId], references: [id], onDelete: Cascade) + collectionId Int + createdBy User @relation(fields: [createdByUserId], references: [id]) + createdByUserId String + createdAt DateTime @default(now()) + + @@index([token]) } \ No newline at end of file diff --git a/scripts/generate-api-token.ts b/scripts/generate-api-token.ts new file mode 100644 index 0000000..f608eed --- /dev/null +++ b/scripts/generate-api-token.ts @@ -0,0 +1,77 @@ +import { PrismaClient } from "@prisma/client"; +import readline from "readline"; +import crypto from "crypto"; + +const prisma = new PrismaClient(); +const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, +}); + +function prompt(question: string): Promise { + return new Promise((resolve) => { + rl.question(question, resolve); + }); +} + +function generateToken(): string { + return crypto.randomBytes(32).toString("hex"); +} + +async function main() { + const userId = await prompt("Enter your User ID: "); + const cid = await prompt("Enter the Collection CID: "); + const name = await prompt( + "Enter a name for this token (e.g., 'LaTeX export'): ", + ); + + const collection = await prisma.collection.findUnique({ + where: { cid }, + }); + + if (!collection) { + console.error(`Collection '${cid}' not found`); + process.exit(1); + } + + const user = await prisma.user.findUnique({ + where: { id: userId }, + }); + + if (!user) { + console.error(`User '${userId}' not found`); + process.exit(1); + } + + const token = generateToken(); + + await prisma.apiToken.create({ + data: { + token, + name, + collectionId: collection.id, + createdByUserId: userId, + }, + }); + + console.log("\nAPI Token created successfully!"); + console.log(`Token: ${token}`); + console.log(`Collection: ${cid}`); + console.log(`Name: ${name}`); + console.log( + `\nUsage: curl -H "Authorization: Bearer ${token}" http://localhost:3000/api/collections/${cid}/problems`, + ); + + rl.close(); +} + +main() + .catch((e) => { + console.error(e); + process.exit(1); + }) + .finally(() => { + prisma.$disconnect().catch((disconnectError) => { + console.error(disconnectError); + }); + });