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
14 changes: 14 additions & 0 deletions src/app/api/friends/messages/route.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -55,6 +56,19 @@ export async function POST(request: Request) {
.select("id, sender_id, recipient_id, body, created_at, read_at")
.single<DirectMessageRow>();

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." },
Expand Down
14 changes: 14 additions & 0 deletions src/app/api/groups/messages/route.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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." },
Expand Down
5 changes: 3 additions & 2 deletions src/components/groups/groups-dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -2166,10 +2167,10 @@ function GroupFriendInviteDialog({
) : null}

{inviteableFriends.length ? (
<PaginatedList
<ChunkedList
chunkSize={20}
className="grid gap-1.5"
items={inviteableFriends}
pageSize={8}
renderItem={(friend) => {
const sent = sentFriendIds.has(friend.id);
const busy = busyFriendIds.has(friend.id);
Expand Down
23 changes: 23 additions & 0 deletions src/lib/message-rate-limit.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
22 changes: 22 additions & 0 deletions src/lib/message-rate-limit.ts
Original file line number Diff line number Diff line change
@@ -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,
};
}
118 changes: 118 additions & 0 deletions supabase/migrations/20261009040000_message_rate_limits.sql
Original file line number Diff line number Diff line change
@@ -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();
Original file line number Diff line number Diff line change
@@ -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';
Loading