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
10 changes: 6 additions & 4 deletions app/api/ai/chat/explain/route.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
export const dynamic = "force-dynamic"

import { NextRequest, NextResponse } from "next/server"
import { getUser } from "@/lib/auth-server"
import { requireAiAccess } from "@/lib/ai-guard"
import { generateAlternateExplanation, EXPLAIN_STYLE_LABELS, type ExplainStyle } from "@/lib/ai"
import { logUsage } from "@/lib/database"
import { getSupabaseAdmin } from "@/lib/supabase-server"
Expand All @@ -10,6 +10,10 @@ const VALID_STYLES: ExplainStyle[] = ["simpler", "analogy", "step-by-step", "rea

export async function POST(req: NextRequest) {
try {
const guard = await requireAiAccess()
if (guard.error) return guard.error
const { user } = guard

const body = await req.json().catch(() => null)
const question = typeof body?.question === "string" ? body.question.trim() : ""
const previousAnswer = typeof body?.previousAnswer === "string" ? body.previousAnswer.trim() : ""
Expand All @@ -30,9 +34,7 @@ export async function POST(req: NextRequest) {
const explanation = await generateAlternateExplanation(question, previousAnswer, style)
const reply = `> **Explained differently — ${EXPLAIN_STYLE_LABELS[style]}**\n\n${explanation}`

const user = await getUser()

if (user && sessionId) {
if (sessionId) {
const admin = await getSupabaseAdmin()
const now = new Date().toISOString()

Expand Down
9 changes: 6 additions & 3 deletions app/api/ai/chat/route.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
export const dynamic = "force-dynamic";

import { NextRequest, NextResponse } from "next/server";
import { getUser } from "@/lib/auth-server";
import { requireAiAccess } from "@/lib/ai-guard";
import { generateAIResponse } from "@/lib/ai";
import { logUsage } from "@/lib/database";
import { getSupabaseAdmin } from "@/lib/supabase-server";
Expand Down Expand Up @@ -43,6 +43,10 @@ function formatReplyWithSources(reply: string, sources: ResourceLink[]) {

export async function POST(req: NextRequest) {
try {
const guard = await requireAiAccess();
if (guard.error) return guard.error;
const { user } = guard;

const body = await req.json();
const message = body.message;
const sessionId = body.sessionId as string | undefined;
Expand Down Expand Up @@ -108,10 +112,9 @@ export async function POST(req: NextRequest) {

const finalReply = formatReplyWithSources(aiResponse, sources);

const user = await getUser();
let savedSessionId: string | null = null;

if (user) {
{
const admin = await getSupabaseAdmin();
let currentSessionId = sessionId;

Expand Down
4 changes: 4 additions & 0 deletions app/api/ai/image/route.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
export const dynamic = "force-dynamic"

import { NextRequest, NextResponse } from "next/server"
import { requireAiAccess } from "@/lib/ai-guard"
import { generateImageWithGemini } from "@/lib/ai-tools"

export async function POST(req: NextRequest) {
try {
const guard = await requireAiAccess()
if (guard.error) return guard.error

const { prompt } = await req.json()

if (!prompt || typeof prompt !== "string" || prompt.trim().length === 0) {
Expand Down
6 changes: 5 additions & 1 deletion app/api/ai/notes/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export const dynamic = "force-dynamic"

import { NextRequest, NextResponse } from "next/server"
import { getUser } from "@/lib/auth-server"
import { requireAiAccess } from "@/lib/ai-guard"
import {
analyzeAttachmentsWithGemini,
searchWithTavily,
Expand Down Expand Up @@ -195,6 +196,10 @@ export async function GET() {

export async function POST(req: NextRequest) {
try {
const guard = await requireAiAccess()
if (guard.error) return guard.error
const { user } = guard

const body = await req.json()

const sourceMode = body?.sourceMode as SourceMode
Expand Down Expand Up @@ -255,7 +260,6 @@ export async function POST(req: NextRequest) {
studyMaterial,
})

const user = await getUser()
let savedNote = null
let saveWarning: string | null = null

Expand Down
4 changes: 4 additions & 0 deletions app/api/ai/transcribe/route.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
export const dynamic = "force-dynamic"

import { NextRequest, NextResponse } from "next/server"
import { requireAiAccess } from "@/lib/ai-guard"

const GROQ_TRANSCRIPTION_URL = "https://api.groq.com/openai/v1/audio/transcriptions"

export async function POST(req: NextRequest) {
try {
const guard = await requireAiAccess()
if (guard.error) return guard.error

const apiKey = process.env.GROQ_API_KEY

if (!apiKey) {
Expand Down
67 changes: 67 additions & 0 deletions lib/ai-guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { NextResponse } from "next/server"
import type { User } from "@supabase/supabase-js"
import { getUser } from "@/lib/auth-server"
import { rateLimit } from "@/lib/rate-limit"
import { consumeCredit } from "@/lib/credits"
import type { FeatureKey } from "@/types"

type GuardSuccess = { user: User; error?: never }
type GuardFailure = { user?: never; error: NextResponse }

/**
* Guards paid AI-generation endpoints against cost abuse.
*
* Runs three checks in order, BEFORE any external AI API is called:
* 1. Authentication -> 401 for anonymous requests
* 2. Rate limiting -> 429 when the per-user burst limit is exceeded
* 3. Credit consumption -> 402 when the user is out of credits
*
* On success it returns the authenticated user. On failure it returns a
* ready-to-return NextResponse so callers can simply do:
*
* const guard = await requireAiAccess()
* if (guard.error) return guard.error
* const { user } = guard
*/
export async function requireAiAccess(
feature: FeatureKey = "ai_chat",
options: { consume?: boolean } = {}
): Promise<GuardSuccess | GuardFailure> {
const user = await getUser()

if (!user) {
return {
error: NextResponse.json(
{ error: "Login required to use this feature.", code: "UNAUTHORIZED", requiresLogin: true },
{ status: 401 }
),
}
}

if (!rateLimit(user.id)) {
return {
error: NextResponse.json(
{ error: "Too many requests. Please wait a minute and try again.", code: "RATE_LIMITED" },
{ status: 429 }
),
}
}

if (options.consume !== false) {
const credit = await consumeCredit(user.id, feature)
if (!credit.allowed) {
return {
error: NextResponse.json(
{
error: "You've run out of AI credits. Upgrade your plan or wait for your credits to refresh.",
code: "NO_CREDITS",
requiresUpgrade: true,
},
{ status: 402 }
),
}
}
}

return { user }
}
Loading