Skip to content

Commit fb29c6b

Browse files
authored
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
1 parent 3ca3899 commit fb29c6b

File tree

11 files changed

+125
-135
lines changed

11 files changed

+125
-135
lines changed

apps/mail/components/ui/app-sidebar.tsx

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,8 @@ import { useSession } from '@/lib/auth-client';
2020
import { useAIFullScreen } from './ai-sidebar';
2121
import { useStats } from '@/hooks/use-stats';
2222
import { useLocation } from 'react-router';
23+
import { cn, FOLDERS } from '@/lib/utils';
2324
import { m } from '@/paraglide/messages';
24-
import { FOLDERS } from '@/lib/utils';
2525
import { Video } from 'lucide-react';
2626
import { NavUser } from './nav-user';
2727
import { NavMain } from './nav-main';
@@ -39,11 +39,8 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
3939
return true;
4040
});
4141
const [, setPricingDialog] = useQueryState('pricingDialog');
42-
4342
const { isFullScreen } = useAIFullScreen();
44-
4543
const { data: stats } = useStats();
46-
4744
const location = useLocation();
4845
const { data: session } = useSession();
4946
const { currentSection, navItems } = useMemo(() => {
@@ -107,15 +104,17 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
107104

108105
{showComposeButton && (
109106
<div className="flex gap-1">
110-
<div className="w-[80%]">
107+
<div className={cn(isPro ? 'w-[80%]' : 'w-full')}>
111108
<ComposeButton />
112109
</div>
113-
<button
114-
onClick={handleCreateMeet}
115-
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]"
116-
>
117-
<Video className="text-muted-foreground h-4 w-4" />
118-
</button>
110+
{isPro ? (
111+
<button
112+
onClick={handleCreateMeet}
113+
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]"
114+
>
115+
<Video className="text-muted-foreground h-4 w-4" />
116+
</button>
117+
) : null}
119118
</div>
120119
)}
121120
</SidebarHeader>

apps/mail/hooks/use-billing.ts

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { useAutumn, useCustomer } from 'autumn-js/react';
22
import { signOut } from '@/lib/auth-client';
3+
import { isProCustomer } from '@/lib/utils';
34
import { useEffect, useMemo } from 'react';
45

56
type FeatureState = {
@@ -58,8 +59,6 @@ const FEATURE_IDS = {
5859
BRAIN: 'brain-activity',
5960
} as const;
6061

61-
const PRO_PLANS = ['pro-example', 'pro_annual', 'team', 'enterprise'] as const;
62-
6362
export const useBilling = () => {
6463
const { customer, refetch, isLoading, error } = useCustomer();
6564
const { attach, track, openBillingPortal } = useAutumn();
@@ -69,12 +68,7 @@ export const useBilling = () => {
6968
}, [error]);
7069

7170
const { isPro, ...customerFeatures } = useMemo(() => {
72-
const isPro =
73-
customer?.products && Array.isArray(customer.products)
74-
? customer.products.some((product) =>
75-
PRO_PLANS.some((plan) => product.id?.includes(plan) || product.name?.includes(plan)),
76-
)
77-
: false;
71+
const isPro = customer ? isProCustomer(customer) : false;
7872

7973
if (!customer?.features) return { isPro, ...DEFAULT_FEATURES };
8074

apps/mail/lib/utils.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { getBrowserTimezone } from './timezones';
33
import { formatInTimeZone } from 'date-fns-tz';
44
import { MAX_URL_LENGTH } from './constants';
55
import { clsx, type ClassValue } from 'clsx';
6+
import type { Customer } from 'autumn-js';
67
import { twMerge } from 'tailwind-merge';
78
import type { Sender } from '@/types';
89
import LZString from 'lz-string';
@@ -617,3 +618,13 @@ export const withExponentialBackoff = async <T>(
617618
}
618619
}
619620
};
621+
622+
const PRO_PLANS = ['pro-example', 'pro_annual', 'team', 'enterprise'] as const;
623+
624+
export const isProCustomer = (customer: Customer) => {
625+
return customer?.products && Array.isArray(customer.products)
626+
? customer.products.some((product) =>
627+
PRO_PLANS.some((plan) => product.id?.includes(plan) || product.name?.includes(plan)),
628+
)
629+
: false;
630+
};

apps/server/src/ctx.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
1-
import type { Env } from './env';
21
import type { Autumn } from 'autumn-js';
32
import type { Auth } from './lib/auth';
3+
import type { ZeroEnv } from './env';
44

55
export type SessionUser = NonNullable<Awaited<ReturnType<Auth['api']['getSession']>>>['user'];
66

77
export type HonoVariables = {
88
auth: Auth;
99
sessionUser?: SessionUser;
10-
autumn: Autumn;
10+
autumn?: Autumn;
1111
};
1212

13-
export type HonoContext = { Variables: HonoVariables; Bindings: Env };
13+
export type HonoContext = { Variables: HonoVariables; Bindings: ZeroEnv };

apps/server/src/lib/auth.ts

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,18 +13,17 @@ import { getBrowserTimezone, isValidTimezone } from './timezones';
1313
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
1414
import { getSocialProviders } from './auth-providers';
1515
import { redis, resend, twilio } from './services';
16-
import { getContext } from 'hono/context-storage';
1716
import { dubAnalytics } from '@dub/better-auth';
1817
import { defaultUserSettings } from './schemas';
1918
import { disableBrainFunction } from './brain';
2019
import { APIError } from 'better-auth/api';
2120
import { getZeroDB } from './server-utils';
2221
import { type EProviders } from '../types';
23-
import type { HonoContext } from '../ctx';
24-
import { env } from '../env';
2522
import { createDriver } from './driver';
23+
import { Autumn } from 'autumn-js';
2624
import { createDb } from '../db';
2725
import { Effect } from 'effect';
26+
import { env } from '../env';
2827
import { Dub } from 'dub';
2928

3029
const scheduleCampaign = (userInfo: { address: string; name: string }) =>
@@ -191,9 +190,9 @@ export const createAuth = () => {
191190
if (!request) throw new APIError('BAD_REQUEST', { message: 'Request object is missing' });
192191
const db = await getZeroDB(user.id);
193192
const connections = await db.findManyConnections();
194-
const context = getContext<HonoContext>();
193+
const autumn = new Autumn({ secretKey: env.AUTUMN_SECRET_KEY });
195194
try {
196-
await context.var.autumn.customers.delete(user.id);
195+
await autumn.customers.delete(user.id);
197196
} catch (error) {
198197
console.error('Failed to delete Autumn customer:', error);
199198
// Continue with deletion process despite Autumn failure

apps/server/src/lib/server-utils.ts

Lines changed: 29 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -364,11 +364,7 @@ export const forceReSync = async (connectionId: string) => {
364364
await agent.stub.forceReSync();
365365
};
366366

367-
type GetThreadsAccumulator = {
368-
threads: any[];
369-
nextPageToken: string | null;
370-
maxResults: number;
371-
};
367+
372368

373369
export const getThreadsFromDB = async (
374370
connectionId: string,
@@ -380,80 +376,40 @@ export const getThreadsFromDB = async (
380376
pageToken?: string;
381377
},
382378
): Promise<IGetThreadsResponse> => {
383-
console.log(`[getThreadsFromDB] Called with connectionId: ${connectionId}, params:`, params);
384-
await sendDoState(connectionId);
379+
// Fire and forget - don't block the thread query on state updates
380+
void sendDoState(connectionId);
385381

386382
const maxResults = params.maxResults ?? 20;
387383

388384
return Effect.runPromise(
389-
aggregateShardDataSequentialEffect<IGetThreadsResponse, GetThreadsAccumulator>(
385+
aggregateShardDataEffect<IGetThreadsResponse>(
390386
connectionId,
391-
(shard, shardId, accumulator) =>
392-
Effect.gen(function* () {
393-
if (accumulator.threads.length >= accumulator.maxResults) {
394-
console.log(
395-
`[getThreadsFromDB] Reached maxResults (${accumulator.maxResults}), breaking loop`,
396-
);
397-
return { shouldContinue: false, accumulator };
398-
}
399-
400-
const remainingResults = accumulator.maxResults - accumulator.threads.length;
401-
console.log(
402-
`[getThreadsFromDB] Querying shard ${shardId} for up to ${remainingResults} threads`,
403-
);
404-
405-
const shardResult = (yield* Effect.promise(() =>
406-
shard.stub.getThreadsFromDB({
407-
...params,
408-
maxResults: remainingResults,
409-
}),
410-
)) as IGetThreadsResponse;
411-
412-
console.log(
413-
`[getThreadsFromDB] Shard ${shardId} returned ${shardResult.threads.length} threads, nextPageToken: ${shardResult.nextPageToken}`,
414-
);
415-
416-
const newThreads = [...accumulator.threads, ...shardResult.threads];
417-
let newNextPageToken = accumulator.nextPageToken;
418-
419-
if (shardResult.nextPageToken) {
420-
newNextPageToken = shardResult.nextPageToken;
421-
console.log(
422-
`[getThreadsFromDB] Setting nextPageToken from shard ${shardId}: ${newNextPageToken}`,
423-
);
424-
}
425-
426-
const shouldContinue =
427-
newThreads.length < accumulator.maxResults &&
428-
shardResult.threads.length >= remainingResults;
429-
430-
if (!shouldContinue) {
431-
console.log(
432-
`[getThreadsFromDB] Stopping after shard ${shardId} (threads.length: ${newThreads.length}, shardResult.threads.length: ${shardResult.threads.length}, remainingResults: ${remainingResults})`,
433-
);
434-
}
435-
436-
return {
437-
shouldContinue,
438-
accumulator: {
439-
threads: newThreads,
440-
nextPageToken: newNextPageToken,
441-
maxResults: accumulator.maxResults,
442-
},
443-
};
444-
}),
445-
{ threads: [], nextPageToken: null, maxResults },
446-
(accumulator) => {
447-
const slicedThreads = accumulator.threads.slice(
448-
0,
449-
maxResults === Infinity ? accumulator.threads.length : maxResults,
450-
);
451-
console.log(
452-
`[getThreadsFromDB] Returning ${slicedThreads.length} threads, nextPageToken: ${accumulator.nextPageToken}`,
453-
);
387+
(shard) =>
388+
Effect.promise(() =>
389+
shard.stub.getThreadsFromDB({
390+
...params,
391+
maxResults: maxResults * 2, // Request more from each shard to ensure we have enough
392+
}),
393+
),
394+
(shardResults) => {
395+
// Combine all threads from all shards
396+
const allThreads = shardResults.flatMap((result) => result.threads);
397+
398+
// Sort by some criteria if needed (assuming threads have a sortable field)
399+
// allThreads.sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime());
400+
401+
// Take only the requested amount
402+
const threads = allThreads.slice(0, maxResults);
403+
404+
// Determine if there's a next page token (simplified logic)
405+
const hasMoreResults = allThreads.length > maxResults;
406+
const nextPageToken = hasMoreResults
407+
? shardResults.find(r => r.nextPageToken)?.nextPageToken || null
408+
: null;
409+
454410
return {
455-
threads: slicedThreads,
456-
nextPageToken: accumulator.nextPageToken,
411+
threads,
412+
nextPageToken,
457413
};
458414
},
459415
),

apps/server/src/lib/utils.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { AppContext, EProviders, Sender } from '../types';
2+
import type { Customer } from 'autumn-js';
23
import { env } from '../env';
34

45
export const parseHeaders = (token: string) => {
@@ -365,3 +366,13 @@ export const cleanSearchValue = (q: string): string => {
365366
.replace(/\s+/g, ' ')
366367
.trim();
367368
};
369+
370+
const PRO_PLANS = ['pro-example', 'pro_annual', 'team', 'enterprise'] as const;
371+
372+
export const isProCustomer = (customer: Customer) => {
373+
return customer?.products && Array.isArray(customer.products)
374+
? customer.products.some((product) =>
375+
PRO_PLANS.some((plan) => product.id?.includes(plan) || product.name?.includes(plan)),
376+
)
377+
: false;
378+
};

apps/server/src/main.ts

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,6 @@ import type { HonoContext } from './ctx';
4545
import { createDb, type DB } from './db';
4646
import { createAuth } from './lib/auth';
4747
import { aiRouter } from './routes/ai';
48-
import { Autumn } from 'autumn-js';
4948
import { appRouter } from './trpc';
5049
import { cors } from 'hono/cors';
5150
import { Hono } from 'hono';
@@ -587,13 +586,9 @@ const api = new Hono<HonoContext>()
587586
}
588587
}
589588

590-
const autumn = new Autumn({ secretKey: env.AUTUMN_SECRET_KEY });
591-
c.set('autumn', autumn);
592-
593589
await next();
594590

595591
c.set('sessionUser', undefined);
596-
c.set('autumn', undefined as any);
597592
c.set('auth', undefined as any);
598593
})
599594
.route('/ai', aiRouter)

0 commit comments

Comments
 (0)