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
71 changes: 71 additions & 0 deletions app/api/drafts/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { NextRequest, NextResponse } from 'next/server';
import { eq } from 'drizzle-orm';
import { getDb, getCfEnv } from '@/lib/db';
import { reportDrafts } from '@/lib/db/schema';
import { decryptText, encryptText } from '@/lib/crypto';
import { ReportDraftDataSchema } from '@/lib/validation';
import { auth } from '@clerk/nextjs/server';

export async function GET() {
const { userId } = await auth();
if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });

const db = getDb(getCfEnv().DB);
const draft = await db.select().from(reportDrafts).where(eq(reportDrafts.clerkUserId, userId)).get();
if (!draft) return NextResponse.json({ draft: null });

try {
const plaintext = await decryptText(draft.data, draft.dataIv);
return NextResponse.json({ draft: { data: JSON.parse(plaintext), updatedAt: draft.updatedAt } });
} catch (err) {
console.error('[GET /api/drafts] Failed to decrypt draft', err);
return NextResponse.json({ draft: null });
}
}

export async function POST(request: NextRequest) {
const { userId } = await auth();
if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });

let body: unknown;
try { body = await request.json(); } catch { return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }); }

const parsed = ReportDraftDataSchema.safeParse((body as { data?: unknown }).data);
if (!parsed.success) {
return NextResponse.json(
{ error: 'Validation failed', details: parsed.error.flatten().fieldErrors },
{ status: 422 },
);
}

const db = getDb(getCfEnv().DB);
const now = Date.now();
const encrypted = await encryptText(JSON.stringify(parsed.data));
const existing = await db.select({ id: reportDrafts.id }).from(reportDrafts).where(eq(reportDrafts.clerkUserId, userId)).get();

if (existing) {
await db.update(reportDrafts)
.set({ data: encrypted.ciphertext, dataIv: encrypted.iv, updatedAt: now })
.where(eq(reportDrafts.clerkUserId, userId));
} else {
await db.insert(reportDrafts).values({
id: crypto.randomUUID(),
clerkUserId: userId,
data: encrypted.ciphertext,
dataIv: encrypted.iv,
createdAt: now,
updatedAt: now,
});
}

return NextResponse.json({ success: true });
}

export async function DELETE() {
const { userId } = await auth();
if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });

const db = getDb(getCfEnv().DB);
await db.delete(reportDrafts).where(eq(reportDrafts.clerkUserId, userId));
return NextResponse.json({ success: true });
}
11 changes: 6 additions & 5 deletions app/api/hacktivity/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,14 @@
* When titleDisclosed is 0, the title is redacted to prevent disclosure
* of undisclosed vulnerability reports.
*/
import { NextRequest, NextResponse } from 'next/server';
import { NextResponse } from 'next/server';
import { getDb, getCfEnv } from '@/lib/db';
import { hacktivity, hallOfFame } from '@/lib/db/schema';
import { desc, eq } from 'drizzle-orm';
import { hacktivity, hallOfFame, researcherStats } from '@/lib/db/schema';
import { and, desc, eq } from 'drizzle-orm';
import { clerkClient } from '@clerk/nextjs/server';
import { getDisplayName } from '@/lib/redact';

export async function GET(_request: NextRequest) {
export async function GET() {
try {
const db = getDb(getCfEnv().DB);

Expand All @@ -33,7 +33,8 @@ export async function GET(_request: NextRequest) {
})
.from(hacktivity)
.innerJoin(hallOfFame, eq(hacktivity.reportId, hallOfFame.reportId))
.where(eq(hallOfFame.isPublic, 1))
.innerJoin(researcherStats, eq(hacktivity.researcherId, researcherStats.researcherId))
.where(and(eq(hallOfFame.isPublic, 1), eq(researcherStats.hofOptOut, 0)))
.orderBy(desc(hacktivity.timestamp))
.limit(100)
.all();
Expand Down
24 changes: 18 additions & 6 deletions app/api/hall-of-fame/route.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,33 @@
/**
* GET /api/hall-of-fame - Public leaderboard
*/
import { NextRequest, NextResponse } from 'next/server';
import { NextResponse } from 'next/server';
import { getDb, getCfEnv } from '@/lib/db';
import { hallOfFame } from '@/lib/db/schema';
import { desc, eq } from 'drizzle-orm';
import { hallOfFame, researcherStats } from '@/lib/db/schema';
import { and, desc, eq } from 'drizzle-orm';
import { clerkClient } from '@clerk/nextjs/server';
import { getDisplayName } from '@/lib/redact';

export async function GET(_request: NextRequest) {
export async function GET() {
try {
const db = getDb(getCfEnv().DB);

const publicAwards = await db
.select()
.select({
id: hallOfFame.id,
reportId: hallOfFame.reportId,
researcherId: hallOfFame.researcherId,
title: hallOfFame.title,
publicTitle: hallOfFame.publicTitle,
severity: hallOfFame.severity,
pointsAwarded: hallOfFame.pointsAwarded,
acceptedAt: hallOfFame.acceptedAt,
isPublic: hallOfFame.isPublic,
createdAt: hallOfFame.createdAt,
})
.from(hallOfFame)
.where(eq(hallOfFame.isPublic, 1))
.innerJoin(researcherStats, eq(hallOfFame.researcherId, researcherStats.researcherId))
.where(and(eq(hallOfFame.isPublic, 1), eq(researcherStats.hofOptOut, 0)))
.orderBy(desc(hallOfFame.acceptedAt))
.all();

Expand Down
18 changes: 12 additions & 6 deletions app/api/hall-of-fame/stats/route.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,27 @@
/**
* GET /api/hall-of-fame/stats - Overall statistics
*/
import { NextRequest, NextResponse } from 'next/server';
import { NextResponse } from 'next/server';
import { getDb, getCfEnv } from '@/lib/db';
import { hallOfFame } from '@/lib/db/schema';
import { eq } from 'drizzle-orm';
import { hallOfFame, researcherStats } from '@/lib/db/schema';
import { and, eq } from 'drizzle-orm';
import { clerkClient } from '@clerk/nextjs/server';
import { getDisplayName } from '@/lib/redact';

export async function GET(_request: NextRequest) {
export async function GET() {
try {
const db = getDb(getCfEnv().DB);

const publicAwards = await db
.select()
.select({
id: hallOfFame.id,
researcherId: hallOfFame.researcherId,
pointsAwarded: hallOfFame.pointsAwarded,
acceptedAt: hallOfFame.acceptedAt,
})
.from(hallOfFame)
.where(eq(hallOfFame.isPublic, 1))
.innerJoin(researcherStats, eq(hallOfFame.researcherId, researcherStats.researcherId))
.where(and(eq(hallOfFame.isPublic, 1), eq(researcherStats.hofOptOut, 0)))
.all();

// Calculate totals
Expand Down
39 changes: 39 additions & 0 deletions app/api/reports/similar/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { NextRequest, NextResponse } from 'next/server';
import { eq } from 'drizzle-orm';
import { getDb, getCfEnv } from '@/lib/db';
import { reports } from '@/lib/db/schema';
import { auth } from '@clerk/nextjs/server';

function wordOverlapScore(a: string, b: string): number {
const tokenize = (s: string) => new Set(s.toLowerCase().split(/\W+/).filter(w => w.length > 3));
const wordsA = tokenize(a);
const wordsB = tokenize(b);
if (wordsA.size === 0 || wordsB.size === 0) return 0;
let matches = 0;
wordsB.forEach(w => { if (wordsA.has(w)) matches++; });
return matches / Math.max(wordsA.size, wordsB.size);
}

export async function GET(request: NextRequest) {
const { userId } = await auth();
if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });

const title = request.nextUrl.searchParams.get('title')?.trim() ?? '';
if (title.length < 10) return NextResponse.json({ similar: [] });

const db = getDb(getCfEnv().DB);
const ownReports = await db
.select({ id: reports.id, refId: reports.refId, title: reports.title, status: reports.status, severity: reports.severity })
.from(reports)
.where(eq(reports.clerkUserId, userId));

const similar = ownReports
.filter(r => r.status !== 'rejected' && r.status !== 'duplicate')
.map(r => ({ ...r, score: wordOverlapScore(title, r.title) }))
.filter(r => r.score >= 0.4)
.sort((a, b) => b.score - a.score)
.slice(0, 3)
.map(({ id, refId, title, status, severity }) => ({ id, refId, title, status, severity }));

return NextResponse.json({ similar });
}
24 changes: 20 additions & 4 deletions app/api/researcher/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
/**
* GET /api/researcher/[id] - Public researcher profile (no auth required)
* GET /api/researcher/[id] - Public researcher profile
*/
import { NextRequest, NextResponse } from 'next/server';
import { getDb, getCfEnv } from '@/lib/db';
import { hallOfFame } from '@/lib/db/schema';
import { hallOfFame, researcherStats } from '@/lib/db/schema';
import { and, eq, desc } from 'drizzle-orm';
import { clerkClient } from '@clerk/nextjs/server';
import { auth, clerkClient } from '@clerk/nextjs/server';
import { getDisplayName } from '@/lib/redact';

export async function GET(
Expand All @@ -14,12 +14,28 @@ export async function GET(
) {
try {
const { id } = await params;
const { userId } = await auth();
const isOwner = userId === id;
const db = getDb(getCfEnv().DB);

const statsRow = await db
.select({ hofOptOut: researcherStats.hofOptOut })
.from(researcherStats)
.where(eq(researcherStats.researcherId, id))
.get();

if ((statsRow?.hofOptOut ?? 0) === 1 && !isOwner) {
return NextResponse.json({ error: 'Researcher not found' }, { status: 404 });
}

const entryVisibility = isOwner
? eq(hallOfFame.researcherId, id)
: and(eq(hallOfFame.researcherId, id), eq(hallOfFame.isPublic, 1));

const publicEntries = await db
.select()
.from(hallOfFame)
.where(and(eq(hallOfFame.researcherId, id), eq(hallOfFame.isPublic, 1)))
.where(entryVisibility)
.orderBy(desc(hallOfFame.acceptedAt))
.all();

Expand Down
55 changes: 55 additions & 0 deletions app/api/researcher/preferences/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { NextRequest, NextResponse } from 'next/server';
import { eq } from 'drizzle-orm';
import { getDb, getCfEnv } from '@/lib/db';
import { researcherStats } from '@/lib/db/schema';
import { auth } from '@clerk/nextjs/server';

export async function GET() {
const { userId } = await auth();
if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });

const db = getDb(getCfEnv().DB);
const row = await db
.select({ hofOptOut: researcherStats.hofOptOut })
.from(researcherStats)
.where(eq(researcherStats.researcherId, userId))
.get();

return NextResponse.json({ hofOptOut: (row?.hofOptOut ?? 0) === 1 });
}

export async function PATCH(request: NextRequest) {
const { userId } = await auth();
if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });

let body: unknown;
try { body = await request.json(); } catch { return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }); }

const { hofOptOut } = body as { hofOptOut?: unknown };
if (typeof hofOptOut !== 'boolean') {
return NextResponse.json({ error: 'hofOptOut must be a boolean' }, { status: 400 });
}

const db = getDb(getCfEnv().DB);
const now = Date.now();
const existing = await db
.select({ researcherId: researcherStats.researcherId })
.from(researcherStats)
.where(eq(researcherStats.researcherId, userId))
.get();

if (existing) {
await db
.update(researcherStats)
.set({ hofOptOut: hofOptOut ? 1 : 0, updatedAt: now })
.where(eq(researcherStats.researcherId, userId));
} else {
await db.insert(researcherStats).values({
researcherId: userId,
hofOptOut: hofOptOut ? 1 : 0,
updatedAt: now,
});
}

return NextResponse.json({ success: true });
}
Loading
Loading