Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add rate-limiting to Commercial License Trial #490

Merged
merged 6 commits into from
Dec 14, 2024
Merged
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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -14,6 +14,7 @@
"build": "next build",
"gen:geist": "ts-node -P scripts/tsconfig.json scripts/geist.ts",
"gen:license": "ts-node -P scripts/tsconfig.json scripts/license.ts",
"gen:db-ts": "supabase gen types typescript --linked > ./src/supabase/database.types.ts",
"lint": "tsc --noEmit && prettier -w */**/*.{ts,tsx} && next lint",
"start": "next start"
},
35 changes: 23 additions & 12 deletions src/app/api/v0/check_email/checkUserInDb.ts
Original file line number Diff line number Diff line change
@@ -14,14 +14,13 @@ type UserWithSub = {
};

/**
* Checks the user's authorization token and retrieves user information.
* Also checks the user's subscription status and sets the rate limit headers.
* Retrieves user information from the database using an API token.
*
* @param req - The NextRequest object.
* @returns A Promise that resolves to a UserWithSub object.
* @throws An error if the API token is missing or invalid.
* @param token - The API token to authenticate with
* @returns The user record if found
* @throws An error if the token is invalid
*/
export async function checkUserInDB(req: NextRequest): Promise<UserWithSub> {
export async function getUserInDB(req: NextRequest): Promise<Tables<"users">> {
const token =
req.headers.get("Authorization") || req.headers.get("authorization");

@@ -53,20 +52,32 @@ export async function checkUserInDB(req: NextRequest): Promise<UserWithSub> {
)
);
}
const user = data[0];
return data[0];
}

/**
* Checks the user's authorization token and retrieves user information.
* Also checks the user's subscription status and sets the rate limit headers.
*
* @param req - The NextRequest object.
* @returns A Promise that resolves to a UserWithSub object.
* @throws An error if the API token is missing or invalid.
*/
export async function checkUserInDB(req: NextRequest): Promise<UserWithSub> {
const user = await getUserInDB(req);

const res2 = await supabaseAdmin
const res = await supabaseAdmin
.from("sub_and_calls")
.select("*")
.eq("user_id", user.id)
.order("current_period_start", { ascending: false })
.limit(1)
.single();
if (res2.error) {
throw convertPgError(res2.error);
if (res.error) {
throw convertPgError(res.error);
}

const subAndCalls = res2.data;
const subAndCalls = res.data;
const numberOfCalls = subAndCalls.number_of_calls || 0;

// Set rate limit headers.
@@ -116,7 +127,7 @@ interface SubAndCalls {
* @param rateLimiterRes - The response object from rate-limiter-flexible.
* @param limit - The limit per interval.
*/
function getRateLimitHeaders(
export function getRateLimitHeaders(
rateLimiterRes: RateLimiterRes,
limit: number
): HeadersInit {
90 changes: 87 additions & 3 deletions src/app/api/v1/commercial_license_trial/route.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
import {
checkUserInDB,
getRateLimitHeaders,
getUserInDB,
isEarlyResponse,
newEarlyResponse,
} from "@/app/api/v0/check_email/checkUserInDb";
import { sentryException } from "@/util/sentry";
import { NextRequest } from "next/server";
import * as Sentry from "@sentry/nextjs";
import { CheckEmailOutput } from "@reacherhq/api";
import { supabaseAdmin } from "@/supabase/supabaseAdmin";
import { convertPgError } from "@/util/helpers";
import { Tables } from "@/supabase/database.types";
import { addDays, differenceInMilliseconds, parseISO } from "date-fns";
import { RateLimiterRes } from "rate-limiter-flexible";

// WorkerOutput is a Result<CheckEmailOutput, TaskError> type in Rust.
type WorkerOutput = {
@@ -19,7 +24,7 @@ type WorkerOutput = {
export async function POST(req: NextRequest): Promise<Response> {
try {
Sentry.setTag("rch.route", "/v1/commercial_license_trial");
const { user } = await checkUserInDB(req);
const { user, rateLimitHeaders } = await checkUserInDB(req);
Sentry.setContext("user", {
supbaseUuid: user.id,
});
@@ -49,7 +54,12 @@ export async function POST(req: NextRequest): Promise<Response> {
throw convertPgError(error);
}

return Response.json({ ok: true });
return Response.json(
{ ok: true },
{
headers: rateLimitHeaders,
}
);
} catch (err) {
if (isEarlyResponse(err)) {
return err.response;
@@ -66,3 +76,77 @@ export async function POST(req: NextRequest): Promise<Response> {
);
}
}

/**
* Checks the user's authorization token and retrieves user information.
* Also checks the user's subscription status and sets the rate limit headers,
* but specifically for the /v1/commercial_license_trial endpoint.
*
* This is a copy of the checkUserInDB function in /v0/check_email, but tailored
* for the /v1/commercial_license_trial endpoint.
*
* @param req - The NextRequest object.
* @returns A Promise that resolves to a UserWithSub object.
* @throws An error if the API token is missing or invalid.
*/
export async function checkUserInDB(req: NextRequest): Promise<{
user: Tables<"users">;
rateLimitHeaders: HeadersInit;
}> {
const user = await getUserInDB(req);

const res = await supabaseAdmin
.from("commercial_license_trial")
.select("*")
.eq("user_id", user.id)
.limit(1)
.single();
if (res.error) {
throw convertPgError(res.error);
}

// If the user has a commercial license trial, we need to check the rate limit.
// First, we limit to 60 calls per minute.
if ((res.data.calls_last_minute || 0) >= 60) {
throw newEarlyResponse(
Response.json(
{
error: "Too many requests this minute. Please try again later.",
},
{ status: 429 }
)
);
}

// Then, we limit to 1000 calls per day.
// We also add rate limit headers to the response.
const now = new Date();
const callsInPast24h = res.data.calls_last_day || 0;
const nextReset = res.data.first_call_in_past_24h
? addDays(parseISO(res.data.first_call_in_past_24h), 1)
: addDays(now, 1);
const msDiff = differenceInMilliseconds(nextReset, now);
const maxInPast24h = 10000; // Currently, hardcoding this to 10000.
const rateLimitHeaders = getRateLimitHeaders(
new RateLimiterRes(
maxInPast24h - callsInPast24h - 1, // -1 because we just consumed 1 email.
msDiff,
callsInPast24h,
undefined
),
maxInPast24h
);

if (callsInPast24h >= maxInPast24h) {
throw newEarlyResponse(
Response.json(
{
error: "Too many requests today. Please contact amaury@reacher.email if you need more verifications for the Commercial License trial.",
},
{ status: 429, headers: rateLimitHeaders }
)
);
}

return { user, rateLimitHeaders };
}
1 change: 0 additions & 1 deletion src/app/auth/callback/route.ts
Original file line number Diff line number Diff line change
@@ -3,7 +3,6 @@ import { NextResponse } from "next/server";
import { cookies } from "next/headers";

export async function GET(request: Request) {
console.log("AAAAAAAAA callback route", request.url);
// The `/auth/callback` route is required for the server-side auth flow implemented
// by the Auth Helpers package. It exchanges an auth code for the user's session.
// https://supabase.com/docs/guides/auth/auth-helpers/nextjs#managing-sign-in-with-code-exchange
263 changes: 187 additions & 76 deletions src/supabase/database.types.ts

Large diffs are not rendered by default.

2 changes: 0 additions & 2 deletions src/supabase/supabaseServer.ts
Original file line number Diff line number Diff line change
@@ -95,7 +95,5 @@ export async function getSubAndCalls(
);
}

console.log("aaa", data[0]);

return data[0];
}
13 changes: 13 additions & 0 deletions supabase/migrations/20241214113511_commercial_license_trial.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
CREATE VIEW commercial_license_trial AS
SELECT
user_id,
COUNT(*) AS calls_last_day,
COUNT(CASE WHEN created_at >= NOW() - INTERVAL '1 minute' THEN 1 END) AS calls_last_minute,
MIN(created_at) AS first_call_in_past_24h
FROM
public.calls
WHERE
endpoint = '/v1/commercial_license_trial'
AND created_at >= NOW() - INTERVAL '24 hours'
GROUP BY
user_id;