diff --git a/src/app/api/friends/messages/route.ts b/src/app/api/friends/messages/route.ts index 7c6ee4d..91bac4f 100644 --- a/src/app/api/friends/messages/route.ts +++ b/src/app/api/friends/messages/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from "next/server"; import { z } from "zod"; import { getServerStudySession } from "@/lib/auth/server-session"; +import { getMessageRateLimitError } from "@/lib/message-rate-limit"; import { sendWebPush } from "@/lib/push/send-web-push"; import { createSupabaseAdminClient, @@ -55,6 +56,19 @@ export async function POST(request: Request) { .select("id, sender_id, recipient_id, body, created_at, read_at") .single(); + const rateLimit = getMessageRateLimitError(error?.message); + if (rateLimit) { + return NextResponse.json( + { message: rateLimit.message }, + { + headers: rateLimit.retryAfterSeconds + ? { "Retry-After": String(rateLimit.retryAfterSeconds) } + : undefined, + status: 429, + }, + ); + } + if (error || !data) { return NextResponse.json( { message: "Messages can only be sent to accepted friends." }, diff --git a/src/app/api/groups/messages/route.ts b/src/app/api/groups/messages/route.ts index 62071e0..d1c6576 100644 --- a/src/app/api/groups/messages/route.ts +++ b/src/app/api/groups/messages/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from "next/server"; import { z } from "zod"; import { getServerStudySession } from "@/lib/auth/server-session"; +import { getMessageRateLimitError } from "@/lib/message-rate-limit"; import { sendWebPush } from "@/lib/push/send-web-push"; import { createSupabaseAdminClient, @@ -83,6 +84,19 @@ export async function POST(request: Request) { .select("id") .single<{ id: string }>(); + const rateLimit = getMessageRateLimitError(error?.message); + if (rateLimit) { + return NextResponse.json( + { message: rateLimit.message }, + { + headers: rateLimit.retryAfterSeconds + ? { "Retry-After": String(rateLimit.retryAfterSeconds) } + : undefined, + status: 429, + }, + ); + } + if (error || !data) { return NextResponse.json( { message: "That group message could not be sent." }, diff --git a/src/components/groups/groups-dashboard.tsx b/src/components/groups/groups-dashboard.tsx index 2f1e54d..b8c18b8 100644 --- a/src/components/groups/groups-dashboard.tsx +++ b/src/components/groups/groups-dashboard.tsx @@ -33,6 +33,7 @@ import { UsersRound, } from "lucide-react"; import { AppDialog } from "@/components/app-dialog"; +import { ChunkedList } from "@/components/chunked-list"; import { EmptyStateCta } from "@/components/empty-state-cta"; import { PaginatedList } from "@/components/paginated-list"; import { useAppHeaderDetail } from "@/components/app-header-detail"; @@ -2166,10 +2167,10 @@ function GroupFriendInviteDialog({ ) : null} {inviteableFriends.length ? ( - { const sent = sentFriendIds.has(friend.id); const busy = busyFriendIds.has(friend.id); diff --git a/src/lib/message-rate-limit.test.ts b/src/lib/message-rate-limit.test.ts new file mode 100644 index 0000000..7b0ccff --- /dev/null +++ b/src/lib/message-rate-limit.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; +import { getMessageRateLimitError } from "./message-rate-limit"; + +describe("getMessageRateLimitError", () => { + it("reads the retry delay from a rate limit error", () => { + expect(getMessageRateLimitError("MESSAGE_RATE_LIMIT:7")).toEqual({ + message: "You're sending messages too fast. Try again in 7s.", + retryAfterSeconds: 7, + }); + }); + + it("flags repeated text", () => { + expect(getMessageRateLimitError("MESSAGE_DUPLICATE")).toEqual({ + message: "You just sent that. Try saying something new.", + retryAfterSeconds: null, + }); + }); + + it("ignores unrelated errors", () => { + expect(getMessageRateLimitError("new row violates row-level security")).toBeNull(); + expect(getMessageRateLimitError(undefined)).toBeNull(); + }); +}); diff --git a/src/lib/message-rate-limit.ts b/src/lib/message-rate-limit.ts new file mode 100644 index 0000000..a59b532 --- /dev/null +++ b/src/lib/message-rate-limit.ts @@ -0,0 +1,22 @@ +// Turns the errors raised by the enforce_message_rate_limit trigger into a +// message people can act on. Returns null for any other error. +export function getMessageRateLimitError(errorMessage: string | undefined) { + if (!errorMessage) return null; + + if (errorMessage.includes("MESSAGE_DUPLICATE")) { + return { + message: "You just sent that. Try saying something new.", + retryAfterSeconds: null, + }; + } + + const match = errorMessage.match(/MESSAGE_RATE_LIMIT:(\d+)/); + if (!match) return null; + + const retryAfterSeconds = Math.max(1, Number.parseInt(match[1], 10)); + + return { + message: `You're sending messages too fast. Try again in ${retryAfterSeconds}s.`, + retryAfterSeconds, + }; +} diff --git a/supabase/migrations/20261009040000_message_rate_limits.sql b/supabase/migrations/20261009040000_message_rate_limits.sql new file mode 100644 index 0000000..aea0172 --- /dev/null +++ b/supabase/migrations/20261009040000_message_rate_limits.sql @@ -0,0 +1,118 @@ +-- Blocks obvious chat spam. Runs as a trigger (not just in the API routes) +-- because signed-in clients can insert into these tables directly through +-- the Data API. Limits are per sender, per table: +-- * at most 6 messages in any 10 seconds (burst) +-- * at most 30 messages in any minute (sustained flood) +-- * the same text at most twice in a minute (copy-paste spam) +-- Errors carry a machine-readable prefix the routes turn into a 429. + +create index if not exists direct_messages_sender_created_idx +on public.direct_messages (sender_id, created_at desc); + +create index if not exists group_chat_messages_user_created_idx +on public.group_chat_messages (user_id, created_at desc); + +create or replace function public.enforce_message_rate_limit() +returns trigger +language plpgsql +security definer +set search_path = public +as $$ +declare + current_sender_id uuid; + normalized_body text := lower(trim(new.body)); + burst_count integer; + oldest_burst_at timestamptz; + minute_count integer; + oldest_minute_at timestamptz; + duplicate_count integer; + retry_after_seconds integer; +begin + if tg_table_name = 'direct_messages' then + current_sender_id := new.sender_id; + else + current_sender_id := new.user_id; + end if; + + -- Serialize one sender's inserts so parallel requests can't slip past + -- the counts together. + perform pg_advisory_xact_lock( + hashtext(tg_table_name), + hashtext(current_sender_id::text) + ); + + if tg_table_name = 'direct_messages' then + select + (count(*) filter ( + where created_at > clock_timestamp() - interval '10 seconds' + ))::integer, + min(created_at) filter ( + where created_at > clock_timestamp() - interval '10 seconds' + ), + count(*)::integer, + min(created_at), + (count(*) filter (where lower(trim(body)) = normalized_body))::integer + into burst_count, oldest_burst_at, minute_count, oldest_minute_at, + duplicate_count + from public.direct_messages + where sender_id = current_sender_id + and created_at > clock_timestamp() - interval '1 minute'; + else + select + (count(*) filter ( + where created_at > clock_timestamp() - interval '10 seconds' + ))::integer, + min(created_at) filter ( + where created_at > clock_timestamp() - interval '10 seconds' + ), + count(*)::integer, + min(created_at), + (count(*) filter (where lower(trim(body)) = normalized_body))::integer + into burst_count, oldest_burst_at, minute_count, oldest_minute_at, + duplicate_count + from public.group_chat_messages + where user_id = current_sender_id + and created_at > clock_timestamp() - interval '1 minute'; + end if; + + if duplicate_count >= 2 then + raise exception 'MESSAGE_DUPLICATE'; + end if; + + if burst_count >= 6 then + retry_after_seconds := greatest( + 1, + ceil(extract(epoch from ( + oldest_burst_at + interval '10 seconds' - clock_timestamp() + )))::integer + ); + raise exception 'MESSAGE_RATE_LIMIT:%', retry_after_seconds; + end if; + + if minute_count >= 30 then + retry_after_seconds := greatest( + 1, + ceil(extract(epoch from ( + oldest_minute_at + interval '1 minute' - clock_timestamp() + )))::integer + ); + raise exception 'MESSAGE_RATE_LIMIT:%', retry_after_seconds; + end if; + + return new; +end; +$$; + +revoke all on function public.enforce_message_rate_limit() +from public, anon, authenticated; + +drop trigger if exists direct_messages_rate_limit on public.direct_messages; +create trigger direct_messages_rate_limit +before insert on public.direct_messages +for each row execute function public.enforce_message_rate_limit(); + +drop trigger if exists group_chat_messages_rate_limit +on public.group_chat_messages; +create trigger group_chat_messages_rate_limit +before insert on public.group_chat_messages +for each row execute function public.enforce_message_rate_limit(); diff --git a/supabase/migrations/20261009050000_fix_notification_grants_and_dm_read_at.sql b/supabase/migrations/20261009050000_fix_notification_grants_and_dm_read_at.sql new file mode 100644 index 0000000..8dd818d --- /dev/null +++ b/supabase/migrations/20261009050000_fix_notification_grants_and_dm_read_at.sql @@ -0,0 +1,49 @@ +-- Fixes errors seen in production Postgres logs. +-- +-- 1. "permission denied for table user_notification_preferences / +-- user_message_mutes / app_notifications": the message, friend request +-- and group invite routes read these with the service role to decide +-- who gets notified. user_message_mutes was never granted to +-- service_role, and the hosted database was missing the others too, so +-- every notification attempt failed. Re-grant explicitly (idempotent). +-- The same goes for the authenticated grants Realtime relies on to +-- filter nudges and notifications by recipient. + +grant select on table + public.user_notification_preferences, + public.user_message_mutes +to service_role; + +grant select, insert, update on table public.app_notifications +to service_role; + +grant select on table public.nudges, public.app_notifications +to authenticated; + +-- 2. "direct_messages violates check constraint +-- direct_messages_read_after_create": the browser marks messages read +-- with its own clock. When that clock runs behind the server, read_at +-- lands before created_at and the whole mark-read update fails, so the +-- messages stay unread. Stamp read_at with server time instead. + +create or replace function public.stamp_direct_message_read_at() +returns trigger +language plpgsql +set search_path = public +as $$ +begin + if new.read_at is not null and old.read_at is null then + new.read_at := greatest(now(), new.created_at); + end if; + + return new; +end; +$$; + +drop trigger if exists direct_messages_stamp_read_at +on public.direct_messages; +create trigger direct_messages_stamp_read_at +before update of read_at on public.direct_messages +for each row execute function public.stamp_direct_message_read_at(); + +notify pgrst, 'reload schema';