Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 97 additions & 0 deletions app/api/collections/[cid]/problems/route.ts
Original file line number Diff line number Diff line change
@@ -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 <token>" },
{ 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,
});
}
23 changes: 23 additions & 0 deletions prisma/migrations/20260118211256_add_api_tokens/migration.sql
Original file line number Diff line number Diff line change
@@ -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;
15 changes: 15 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ model User {
comments Comment[]
problemLikes ProblemLike[]
solveAttempts SolveAttempt[]
apiTokens ApiToken[]
}

model Account {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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])
}
77 changes: 77 additions & 0 deletions scripts/generate-api-token.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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);
});
});