mirror of
https://github.com/Mail-0/Zero.git
synced 2026-06-28 06:46:15 +00:00
Add rate limiting to meet creation endpoint (#1950)
# Restrict Video Meeting Creation to Pro Users ## Description This PR restricts the video meeting creation functionality to Pro users only. It also adds rate limiting to the meeting creation endpoint to prevent abuse. The UI has been updated to hide the video meeting button for non-Pro users. ## Type of Change - [x] ✨ New feature (non-breaking change which adds functionality) - [x] 🔒 Security enhancement - [x] ⚡ Performance improvement ## Areas Affected - [x] User Interface/Experience - [x] Authentication/Authorization - [x] API Endpoints ## Testing Done - [x] Manual testing performed ## Security Considerations - [x] Authentication checks are in place - [x] Rate limiting is implemented ## Checklist - [x] I have performed a self-review of my code - [x] My changes generate no new warnings ## Additional Notes The PR includes: 1. Refactoring the Pro user detection logic into a reusable utility function 2. Adding rate limiting to the meeting creation endpoint (10 requests per minute) 3. Conditionally rendering the video meeting button in the sidebar based on Pro status 4. Proper error handling for unauthorized meeting creation attempts
This commit is contained in:
@@ -20,8 +20,8 @@ import { useSession } from '@/lib/auth-client';
|
||||
import { useAIFullScreen } from './ai-sidebar';
|
||||
import { useStats } from '@/hooks/use-stats';
|
||||
import { useLocation } from 'react-router';
|
||||
import { cn, FOLDERS } from '@/lib/utils';
|
||||
import { m } from '@/paraglide/messages';
|
||||
import { FOLDERS } from '@/lib/utils';
|
||||
import { Video } from 'lucide-react';
|
||||
import { NavUser } from './nav-user';
|
||||
import { NavMain } from './nav-main';
|
||||
@@ -39,11 +39,8 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
return true;
|
||||
});
|
||||
const [, setPricingDialog] = useQueryState('pricingDialog');
|
||||
|
||||
const { isFullScreen } = useAIFullScreen();
|
||||
|
||||
const { data: stats } = useStats();
|
||||
|
||||
const location = useLocation();
|
||||
const { data: session } = useSession();
|
||||
const { currentSection, navItems } = useMemo(() => {
|
||||
@@ -107,15 +104,17 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
|
||||
{showComposeButton && (
|
||||
<div className="flex gap-1">
|
||||
<div className="w-[80%]">
|
||||
<div className={cn(isPro ? 'w-[80%]' : 'w-full')}>
|
||||
<ComposeButton />
|
||||
</div>
|
||||
<button
|
||||
onClick={handleCreateMeet}
|
||||
className="hover:bg-muted-foreground/10 inline-flex h-8 w-[20%] items-center justify-center gap-1 overflow-hidden rounded-lg border bg-white px-1.5 dark:border-none dark:bg-[#313131]"
|
||||
>
|
||||
<Video className="text-muted-foreground h-4 w-4" />
|
||||
</button>
|
||||
{isPro ? (
|
||||
<button
|
||||
onClick={handleCreateMeet}
|
||||
className="hover:bg-muted-foreground/10 inline-flex h-8 w-[20%] items-center justify-center gap-1 overflow-hidden rounded-lg border bg-white px-1.5 dark:border-none dark:bg-[#313131]"
|
||||
>
|
||||
<Video className="text-muted-foreground h-4 w-4" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</SidebarHeader>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useAutumn, useCustomer } from 'autumn-js/react';
|
||||
import { signOut } from '@/lib/auth-client';
|
||||
import { isProCustomer } from '@/lib/utils';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
|
||||
type FeatureState = {
|
||||
@@ -58,8 +59,6 @@ const FEATURE_IDS = {
|
||||
BRAIN: 'brain-activity',
|
||||
} as const;
|
||||
|
||||
const PRO_PLANS = ['pro-example', 'pro_annual', 'team', 'enterprise'] as const;
|
||||
|
||||
export const useBilling = () => {
|
||||
const { customer, refetch, isLoading, error } = useCustomer();
|
||||
const { attach, track, openBillingPortal } = useAutumn();
|
||||
@@ -69,12 +68,7 @@ export const useBilling = () => {
|
||||
}, [error]);
|
||||
|
||||
const { isPro, ...customerFeatures } = useMemo(() => {
|
||||
const isPro =
|
||||
customer?.products && Array.isArray(customer.products)
|
||||
? customer.products.some((product) =>
|
||||
PRO_PLANS.some((plan) => product.id?.includes(plan) || product.name?.includes(plan)),
|
||||
)
|
||||
: false;
|
||||
const isPro = customer ? isProCustomer(customer) : false;
|
||||
|
||||
if (!customer?.features) return { isPro, ...DEFAULT_FEATURES };
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { getBrowserTimezone } from './timezones';
|
||||
import { formatInTimeZone } from 'date-fns-tz';
|
||||
import { MAX_URL_LENGTH } from './constants';
|
||||
import { clsx, type ClassValue } from 'clsx';
|
||||
import type { Customer } from 'autumn-js';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import type { Sender } from '@/types';
|
||||
import LZString from 'lz-string';
|
||||
@@ -617,3 +618,13 @@ export const withExponentialBackoff = async <T>(
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const PRO_PLANS = ['pro-example', 'pro_annual', 'team', 'enterprise'] as const;
|
||||
|
||||
export const isProCustomer = (customer: Customer) => {
|
||||
return customer?.products && Array.isArray(customer.products)
|
||||
? customer.products.some((product) =>
|
||||
PRO_PLANS.some((plan) => product.id?.includes(plan) || product.name?.includes(plan)),
|
||||
)
|
||||
: false;
|
||||
};
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import type { Env } from './env';
|
||||
import type { Autumn } from 'autumn-js';
|
||||
import type { Auth } from './lib/auth';
|
||||
import type { ZeroEnv } from './env';
|
||||
|
||||
export type SessionUser = NonNullable<Awaited<ReturnType<Auth['api']['getSession']>>>['user'];
|
||||
|
||||
export type HonoVariables = {
|
||||
auth: Auth;
|
||||
sessionUser?: SessionUser;
|
||||
autumn: Autumn;
|
||||
autumn?: Autumn;
|
||||
};
|
||||
|
||||
export type HonoContext = { Variables: HonoVariables; Bindings: Env };
|
||||
export type HonoContext = { Variables: HonoVariables; Bindings: ZeroEnv };
|
||||
|
||||
@@ -13,18 +13,17 @@ import { getBrowserTimezone, isValidTimezone } from './timezones';
|
||||
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
|
||||
import { getSocialProviders } from './auth-providers';
|
||||
import { redis, resend, twilio } from './services';
|
||||
import { getContext } from 'hono/context-storage';
|
||||
import { dubAnalytics } from '@dub/better-auth';
|
||||
import { defaultUserSettings } from './schemas';
|
||||
import { disableBrainFunction } from './brain';
|
||||
import { APIError } from 'better-auth/api';
|
||||
import { getZeroDB } from './server-utils';
|
||||
import { type EProviders } from '../types';
|
||||
import type { HonoContext } from '../ctx';
|
||||
import { env } from '../env';
|
||||
import { createDriver } from './driver';
|
||||
import { Autumn } from 'autumn-js';
|
||||
import { createDb } from '../db';
|
||||
import { Effect } from 'effect';
|
||||
import { env } from '../env';
|
||||
import { Dub } from 'dub';
|
||||
|
||||
const scheduleCampaign = (userInfo: { address: string; name: string }) =>
|
||||
@@ -191,9 +190,9 @@ export const createAuth = () => {
|
||||
if (!request) throw new APIError('BAD_REQUEST', { message: 'Request object is missing' });
|
||||
const db = await getZeroDB(user.id);
|
||||
const connections = await db.findManyConnections();
|
||||
const context = getContext<HonoContext>();
|
||||
const autumn = new Autumn({ secretKey: env.AUTUMN_SECRET_KEY });
|
||||
try {
|
||||
await context.var.autumn.customers.delete(user.id);
|
||||
await autumn.customers.delete(user.id);
|
||||
} catch (error) {
|
||||
console.error('Failed to delete Autumn customer:', error);
|
||||
// Continue with deletion process despite Autumn failure
|
||||
|
||||
@@ -364,11 +364,7 @@ export const forceReSync = async (connectionId: string) => {
|
||||
await agent.stub.forceReSync();
|
||||
};
|
||||
|
||||
type GetThreadsAccumulator = {
|
||||
threads: any[];
|
||||
nextPageToken: string | null;
|
||||
maxResults: number;
|
||||
};
|
||||
|
||||
|
||||
export const getThreadsFromDB = async (
|
||||
connectionId: string,
|
||||
@@ -380,80 +376,40 @@ export const getThreadsFromDB = async (
|
||||
pageToken?: string;
|
||||
},
|
||||
): Promise<IGetThreadsResponse> => {
|
||||
console.log(`[getThreadsFromDB] Called with connectionId: ${connectionId}, params:`, params);
|
||||
await sendDoState(connectionId);
|
||||
// Fire and forget - don't block the thread query on state updates
|
||||
void sendDoState(connectionId);
|
||||
|
||||
const maxResults = params.maxResults ?? 20;
|
||||
|
||||
return Effect.runPromise(
|
||||
aggregateShardDataSequentialEffect<IGetThreadsResponse, GetThreadsAccumulator>(
|
||||
aggregateShardDataEffect<IGetThreadsResponse>(
|
||||
connectionId,
|
||||
(shard, shardId, accumulator) =>
|
||||
Effect.gen(function* () {
|
||||
if (accumulator.threads.length >= accumulator.maxResults) {
|
||||
console.log(
|
||||
`[getThreadsFromDB] Reached maxResults (${accumulator.maxResults}), breaking loop`,
|
||||
);
|
||||
return { shouldContinue: false, accumulator };
|
||||
}
|
||||
(shard) =>
|
||||
Effect.promise(() =>
|
||||
shard.stub.getThreadsFromDB({
|
||||
...params,
|
||||
maxResults: maxResults * 2, // Request more from each shard to ensure we have enough
|
||||
}),
|
||||
),
|
||||
(shardResults) => {
|
||||
// Combine all threads from all shards
|
||||
const allThreads = shardResults.flatMap((result) => result.threads);
|
||||
|
||||
// Sort by some criteria if needed (assuming threads have a sortable field)
|
||||
// allThreads.sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime());
|
||||
|
||||
// Take only the requested amount
|
||||
const threads = allThreads.slice(0, maxResults);
|
||||
|
||||
// Determine if there's a next page token (simplified logic)
|
||||
const hasMoreResults = allThreads.length > maxResults;
|
||||
const nextPageToken = hasMoreResults
|
||||
? shardResults.find(r => r.nextPageToken)?.nextPageToken || null
|
||||
: null;
|
||||
|
||||
const remainingResults = accumulator.maxResults - accumulator.threads.length;
|
||||
console.log(
|
||||
`[getThreadsFromDB] Querying shard ${shardId} for up to ${remainingResults} threads`,
|
||||
);
|
||||
|
||||
const shardResult = (yield* Effect.promise(() =>
|
||||
shard.stub.getThreadsFromDB({
|
||||
...params,
|
||||
maxResults: remainingResults,
|
||||
}),
|
||||
)) as IGetThreadsResponse;
|
||||
|
||||
console.log(
|
||||
`[getThreadsFromDB] Shard ${shardId} returned ${shardResult.threads.length} threads, nextPageToken: ${shardResult.nextPageToken}`,
|
||||
);
|
||||
|
||||
const newThreads = [...accumulator.threads, ...shardResult.threads];
|
||||
let newNextPageToken = accumulator.nextPageToken;
|
||||
|
||||
if (shardResult.nextPageToken) {
|
||||
newNextPageToken = shardResult.nextPageToken;
|
||||
console.log(
|
||||
`[getThreadsFromDB] Setting nextPageToken from shard ${shardId}: ${newNextPageToken}`,
|
||||
);
|
||||
}
|
||||
|
||||
const shouldContinue =
|
||||
newThreads.length < accumulator.maxResults &&
|
||||
shardResult.threads.length >= remainingResults;
|
||||
|
||||
if (!shouldContinue) {
|
||||
console.log(
|
||||
`[getThreadsFromDB] Stopping after shard ${shardId} (threads.length: ${newThreads.length}, shardResult.threads.length: ${shardResult.threads.length}, remainingResults: ${remainingResults})`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
shouldContinue,
|
||||
accumulator: {
|
||||
threads: newThreads,
|
||||
nextPageToken: newNextPageToken,
|
||||
maxResults: accumulator.maxResults,
|
||||
},
|
||||
};
|
||||
}),
|
||||
{ threads: [], nextPageToken: null, maxResults },
|
||||
(accumulator) => {
|
||||
const slicedThreads = accumulator.threads.slice(
|
||||
0,
|
||||
maxResults === Infinity ? accumulator.threads.length : maxResults,
|
||||
);
|
||||
console.log(
|
||||
`[getThreadsFromDB] Returning ${slicedThreads.length} threads, nextPageToken: ${accumulator.nextPageToken}`,
|
||||
);
|
||||
return {
|
||||
threads: slicedThreads,
|
||||
nextPageToken: accumulator.nextPageToken,
|
||||
threads,
|
||||
nextPageToken,
|
||||
};
|
||||
},
|
||||
),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { AppContext, EProviders, Sender } from '../types';
|
||||
import type { Customer } from 'autumn-js';
|
||||
import { env } from '../env';
|
||||
|
||||
export const parseHeaders = (token: string) => {
|
||||
@@ -365,3 +366,13 @@ export const cleanSearchValue = (q: string): string => {
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
};
|
||||
|
||||
const PRO_PLANS = ['pro-example', 'pro_annual', 'team', 'enterprise'] as const;
|
||||
|
||||
export const isProCustomer = (customer: Customer) => {
|
||||
return customer?.products && Array.isArray(customer.products)
|
||||
? customer.products.some((product) =>
|
||||
PRO_PLANS.some((plan) => product.id?.includes(plan) || product.name?.includes(plan)),
|
||||
)
|
||||
: false;
|
||||
};
|
||||
|
||||
@@ -45,7 +45,6 @@ import type { HonoContext } from './ctx';
|
||||
import { createDb, type DB } from './db';
|
||||
import { createAuth } from './lib/auth';
|
||||
import { aiRouter } from './routes/ai';
|
||||
import { Autumn } from 'autumn-js';
|
||||
import { appRouter } from './trpc';
|
||||
import { cors } from 'hono/cors';
|
||||
import { Hono } from 'hono';
|
||||
@@ -587,13 +586,9 @@ const api = new Hono<HonoContext>()
|
||||
}
|
||||
}
|
||||
|
||||
const autumn = new Autumn({ secretKey: env.AUTUMN_SECRET_KEY });
|
||||
c.set('autumn', autumn);
|
||||
|
||||
await next();
|
||||
|
||||
c.set('sessionUser', undefined);
|
||||
c.set('autumn', undefined as any);
|
||||
c.set('auth', undefined as any);
|
||||
})
|
||||
.route('/ai', aiRouter)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { fetchPricingTable } from 'autumn-js';
|
||||
import { Autumn, fetchPricingTable } from 'autumn-js';
|
||||
import type { HonoContext } from '../ctx';
|
||||
import { env } from '../env';
|
||||
import { Hono } from 'hono';
|
||||
@@ -38,6 +38,7 @@ export const autumnApi = new Hono<AutumnContext>()
|
||||
},
|
||||
},
|
||||
);
|
||||
c.set('autumn', new Autumn({ secretKey: env.AUTUMN_SECRET_KEY }));
|
||||
await next();
|
||||
})
|
||||
.post('/customers', async (c) => {
|
||||
@@ -46,7 +47,7 @@ export const autumnApi = new Hono<AutumnContext>()
|
||||
if (!customerData) return c.json({ error: 'No customer ID found' }, 401);
|
||||
|
||||
return c.json(
|
||||
await autumn.customers
|
||||
await autumn!.customers
|
||||
.create({
|
||||
id: customerData.customerId,
|
||||
...customerData.customerData,
|
||||
@@ -62,7 +63,7 @@ export const autumnApi = new Hono<AutumnContext>()
|
||||
if (!customerData) return c.json({ error: 'No customer ID found' }, 401);
|
||||
|
||||
return c.json(
|
||||
await autumn
|
||||
await autumn!
|
||||
.attach({
|
||||
...sanitizedBody,
|
||||
customer_id: customerData.customerId,
|
||||
@@ -78,7 +79,7 @@ export const autumnApi = new Hono<AutumnContext>()
|
||||
if (!customerData) return c.json({ error: 'No customer ID found' }, 401);
|
||||
|
||||
return c.json(
|
||||
await autumn
|
||||
await autumn!
|
||||
.cancel({
|
||||
...sanitizedBody,
|
||||
customer_id: customerData.customerId,
|
||||
@@ -93,7 +94,7 @@ export const autumnApi = new Hono<AutumnContext>()
|
||||
if (!customerData) return c.json({ error: 'No customer ID found' }, 401);
|
||||
|
||||
return c.json(
|
||||
await autumn
|
||||
await autumn!
|
||||
.check({
|
||||
...sanitizedBody,
|
||||
customer_id: customerData.customerId,
|
||||
@@ -109,7 +110,7 @@ export const autumnApi = new Hono<AutumnContext>()
|
||||
if (!customerData) return c.json({ error: 'No customer ID found' }, 401);
|
||||
|
||||
return c.json(
|
||||
await autumn
|
||||
await autumn!
|
||||
.track({
|
||||
...sanitizedBody,
|
||||
customer_id: customerData.customerId,
|
||||
@@ -124,7 +125,9 @@ export const autumnApi = new Hono<AutumnContext>()
|
||||
if (!customerData) return c.json({ error: 'No customer ID found' }, 401);
|
||||
|
||||
return c.json(
|
||||
await autumn.customers.billingPortal(customerData.customerId, body).then((data) => data.data),
|
||||
await autumn!.customers
|
||||
.billingPortal(customerData.customerId, body)
|
||||
.then((data) => data.data),
|
||||
);
|
||||
})
|
||||
.post('/openBillingPortal', async (c) => {
|
||||
@@ -133,7 +136,7 @@ export const autumnApi = new Hono<AutumnContext>()
|
||||
if (!customerData) return c.json({ error: 'No customer ID found' }, 401);
|
||||
|
||||
return c.json(
|
||||
await autumn.customers
|
||||
await autumn!.customers
|
||||
.billingPortal(customerData.customerId, {
|
||||
...body,
|
||||
return_url: `${env.VITE_PUBLIC_APP_URL}`,
|
||||
@@ -147,7 +150,7 @@ export const autumnApi = new Hono<AutumnContext>()
|
||||
if (!customerData) return c.json({ error: 'No customer ID found' }, 401);
|
||||
|
||||
return c.json(
|
||||
await autumn.entities.create(customerData.customerId, body).then((data) => data.data),
|
||||
await autumn!.entities.create(customerData.customerId, body).then((data) => data.data),
|
||||
);
|
||||
})
|
||||
.get('/entities/:entityId', async (c) => {
|
||||
@@ -168,7 +171,7 @@ export const autumnApi = new Hono<AutumnContext>()
|
||||
}
|
||||
|
||||
return c.json(
|
||||
await autumn.entities
|
||||
await autumn!.entities
|
||||
.get(customerData.customerId, entityId, { expand })
|
||||
.then((data) => data.data),
|
||||
);
|
||||
@@ -190,7 +193,7 @@ export const autumnApi = new Hono<AutumnContext>()
|
||||
}
|
||||
|
||||
return c.json(
|
||||
await autumn.entities.delete(customerData.customerId, entityId).then((data) => data.data),
|
||||
await autumn!.entities.delete(customerData.customerId, entityId).then((data) => data.data),
|
||||
);
|
||||
})
|
||||
.get('/components/pricing_table', async (c) => {
|
||||
@@ -198,7 +201,7 @@ export const autumnApi = new Hono<AutumnContext>()
|
||||
|
||||
return c.json(
|
||||
await fetchPricingTable({
|
||||
instance: autumn,
|
||||
instance: autumn!,
|
||||
params: {
|
||||
customer_id: customerData?.customerId || undefined,
|
||||
},
|
||||
|
||||
@@ -47,6 +47,5 @@ export const serverTrpc = () => {
|
||||
c,
|
||||
sessionUser: c.var.sessionUser,
|
||||
auth: c.var.auth,
|
||||
autumn: c.var.autumn,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { activeDriverProcedure, router } from '../trpc';
|
||||
import { activeDriverProcedure, createRateLimiterMiddleware, router } from '../trpc';
|
||||
import { isProCustomer } from '../../lib/utils';
|
||||
import { Ratelimit } from '@upstash/ratelimit';
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { Autumn } from 'autumn-js';
|
||||
import { env } from '../../env';
|
||||
|
||||
type MeetResponse = {
|
||||
@@ -18,22 +21,42 @@ type MeetResponse = {
|
||||
};
|
||||
|
||||
export const meetRouter = router({
|
||||
create: activeDriverProcedure.mutation(async () => {
|
||||
const AuthHeader = env.MEET_AUTH_HEADER;
|
||||
const response = await fetch(env.MEET_API_URL + '/meetings', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: AuthHeader,
|
||||
},
|
||||
});
|
||||
create: activeDriverProcedure
|
||||
.use(
|
||||
createRateLimiterMiddleware({
|
||||
limiter: Ratelimit.slidingWindow(10, '1m'),
|
||||
generatePrefix: ({ sessionUser }) => `ratelimit:meet-create-${sessionUser?.id}`,
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx }) => {
|
||||
const autumn = new Autumn({ secretKey: env.AUTUMN_SECRET_KEY });
|
||||
const customer = await autumn.customers.get(ctx.sessionUser?.id);
|
||||
if (!customer.data) {
|
||||
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Customer not found' });
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(await response.text());
|
||||
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Failed to create meeting' });
|
||||
}
|
||||
if (!isProCustomer(customer.data)) {
|
||||
throw new TRPCError({
|
||||
code: 'UNAUTHORIZED',
|
||||
message: 'Customer is not a pro customer, please upgrade to a pro plan',
|
||||
});
|
||||
}
|
||||
|
||||
const data = await response.json<MeetResponse>();
|
||||
return data;
|
||||
}),
|
||||
const AuthHeader = env.MEET_AUTH_HEADER;
|
||||
const response = await fetch(env.MEET_API_URL + '/meetings', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: AuthHeader,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(await response.text());
|
||||
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Failed to create meeting' });
|
||||
}
|
||||
|
||||
const data = await response.json<MeetResponse>();
|
||||
return data;
|
||||
}),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user