diff --git a/.env.example b/.env.example index 70c83d0..dcbe69c 100644 --- a/.env.example +++ b/.env.example @@ -7,3 +7,8 @@ DATABASE_URL= POLYCORD_ADMIN_USER_IDS= # Set to "true" to disable all product analytics tracking POLYCORD_ANALYTICS_DISABLED= +# Rate limit overrides as "max:windowSeconds" (e.g. 5:3600); defaults apply when unset +POLYCORD_RATE_LIMIT_REPORT= +POLYCORD_RATE_LIMIT_BUMP= +POLYCORD_RATE_LIMIT_COPY= +POLYCORD_RATE_LIMIT_AUTH_FAILURE= diff --git a/drizzle/0011_cute_thor.sql b/drizzle/0011_cute_thor.sql new file mode 100644 index 0000000..e95960a --- /dev/null +++ b/drizzle/0011_cute_thor.sql @@ -0,0 +1,19 @@ +CREATE TABLE "rate_limit_counters" ( + "scope" varchar(32) NOT NULL, + "subject" varchar(128) NOT NULL, + "window_start" timestamp with time zone NOT NULL, + "count" integer NOT NULL, + CONSTRAINT "rate_limit_counters_scope_subject_pk" PRIMARY KEY("scope","subject") +); +--> statement-breakpoint +CREATE TABLE "suspicious_activity" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "action" varchar(32) NOT NULL, + "user_id" uuid, + "ip" varchar(64), + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "suspicious_activity" ADD CONSTRAINT "suspicious_activity_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "suspicious_activity_created_at_idx" ON "suspicious_activity" USING btree ("created_at");--> statement-breakpoint +CREATE INDEX "suspicious_activity_user_id_idx" ON "suspicious_activity" USING btree ("user_id"); \ No newline at end of file diff --git a/drizzle/meta/0011_snapshot.json b/drizzle/meta/0011_snapshot.json new file mode 100644 index 0000000..fd190fb --- /dev/null +++ b/drizzle/meta/0011_snapshot.json @@ -0,0 +1,1410 @@ +{ + "id": "a69594d6-3652-4a50-bf90-1d562c7056bf", + "prevId": "66847fa2-934e-4d42-88b2-8a1bf4fa7ca6", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.analytics_events": { + "name": "analytics_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "anonymous_id": { + "name": "anonymous_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "locale": { + "name": "locale", + "type": "varchar(16)", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "analytics_events_name_idx": { + "name": "analytics_events_name_idx", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "analytics_events_created_at_idx": { + "name": "analytics_events_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "analytics_events_user_id_idx": { + "name": "analytics_events_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "analytics_events_user_id_users_id_fk": { + "name": "analytics_events_user_id_users_id_fk", + "tableFrom": "analytics_events", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notifications": { + "name": "notifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "notification_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "actor_name": { + "name": "actor_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_avatar_url": { + "name": "actor_avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_guest": { + "name": "is_guest", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "read": { + "name": "read", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "notifications_user_id_created_at_idx": { + "name": "notifications_user_id_created_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notifications_user_id_read_idx": { + "name": "notifications_user_id_read_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "read", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "notifications_user_id_users_id_fk": { + "name": "notifications_user_id_users_id_fk", + "tableFrom": "notifications", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.profile_target_languages": { + "name": "profile_target_languages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "profile_id": { + "name": "profile_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "language": { + "name": "language", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true + }, + "proficiency_level": { + "name": "proficiency_level", + "type": "proficiency_level", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "profile_target_languages_profile_id_idx": { + "name": "profile_target_languages_profile_id_idx", + "columns": [ + { + "expression": "profile_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "profile_target_languages_language_idx": { + "name": "profile_target_languages_language_idx", + "columns": [ + { + "expression": "language", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "profile_target_languages_profile_language_idx": { + "name": "profile_target_languages_profile_language_idx", + "columns": [ + { + "expression": "profile_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "language", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "profile_target_languages_profile_position_idx": { + "name": "profile_target_languages_profile_position_idx", + "columns": [ + { + "expression": "profile_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "profile_target_languages_profile_id_profiles_id_fk": { + "name": "profile_target_languages_profile_id_profiles_id_fk", + "tableFrom": "profile_target_languages", + "tableTo": "profiles", + "columnsFrom": ["profile_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "profile_target_languages_position_check": { + "name": "profile_target_languages_position_check", + "value": "\"profile_target_languages\".\"position\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.profiles": { + "name": "profiles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "allow_anonymous_copy": { + "name": "allow_anonymous_copy", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "display_timezone": { + "name": "display_timezone", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "display_availability": { + "name": "display_availability", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "primary_language": { + "name": "primary_language", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true + }, + "target_language": { + "name": "target_language", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true + }, + "proficiency_level": { + "name": "proficiency_level", + "type": "proficiency_level", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "availability": { + "name": "availability", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'flexible'" + }, + "availability_days": { + "name": "availability_days", + "type": "varchar(16)", + "primaryKey": false, + "notNull": false + }, + "availability_from": { + "name": "availability_from", + "type": "varchar(5)", + "primaryKey": false, + "notNull": false + }, + "availability_to": { + "name": "availability_to", + "type": "varchar(5)", + "primaryKey": false, + "notNull": false + }, + "availability_any_time": { + "name": "availability_any_time", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "tags": { + "name": "tags", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "country": { + "name": "country", + "type": "varchar(2)", + "primaryKey": false, + "notNull": false + }, + "timezone": { + "name": "timezone", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "last_bumped_at": { + "name": "last_bumped_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "profiles_user_id_idx": { + "name": "profiles_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "profiles_public_idx": { + "name": "profiles_public_idx", + "columns": [ + { + "expression": "is_public", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "profiles_primary_language_idx": { + "name": "profiles_primary_language_idx", + "columns": [ + { + "expression": "primary_language", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "profiles_target_language_idx": { + "name": "profiles_target_language_idx", + "columns": [ + { + "expression": "target_language", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "profiles_country_idx": { + "name": "profiles_country_idx", + "columns": [ + { + "expression": "country", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "profiles_last_bumped_at_idx": { + "name": "profiles_last_bumped_at_idx", + "columns": [ + { + "expression": "last_bumped_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "profiles_user_id_users_id_fk": { + "name": "profiles_user_id_users_id_fk", + "tableFrom": "profiles", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "profiles_bio_length_check": { + "name": "profiles_bio_length_check", + "value": "char_length(\"profiles\".\"bio\") between 10 and 500" + }, + "profiles_availability_check": { + "name": "profiles_availability_check", + "value": "\"profiles\".\"availability\" in ('weeknights', 'weekends', 'weekday_mornings', 'flexible')" + }, + "profiles_availability_days_check": { + "name": "profiles_availability_days_check", + "value": "\"profiles\".\"availability_days\" is null or \"profiles\".\"availability_days\" in ('any', 'weekdays', 'weekends')" + }, + "profiles_availability_from_check": { + "name": "profiles_availability_from_check", + "value": "\"profiles\".\"availability_from\" is null or \"profiles\".\"availability_from\" ~ '^[0-2][0-9]:[0-5][0-9]$'" + }, + "profiles_availability_to_check": { + "name": "profiles_availability_to_check", + "value": "\"profiles\".\"availability_to\" is null or \"profiles\".\"availability_to\" ~ '^[0-2][0-9]:[0-5][0-9]$'" + }, + "profiles_tags_limit_check": { + "name": "profiles_tags_limit_check", + "value": "cardinality(\"profiles\".\"tags\") <= 8" + } + }, + "isRLSEnabled": false + }, + "public.rate_limit_counters": { + "name": "rate_limit_counters", + "schema": "", + "columns": { + "scope": { + "name": "scope", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "window_start": { + "name": "window_start", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "rate_limit_counters_scope_subject_pk": { + "name": "rate_limit_counters_scope_subject_pk", + "columns": ["scope", "subject"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reports": { + "name": "reports", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "reporter_user_id": { + "name": "reporter_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "reported_user_id": { + "name": "reported_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "reported_profile_id": { + "name": "reported_profile_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "reason": { + "name": "reason", + "type": "report_reason", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "details": { + "name": "details", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "report_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "reports_reported_user_id_idx": { + "name": "reports_reported_user_id_idx", + "columns": [ + { + "expression": "reported_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reports_reporter_user_id_idx": { + "name": "reports_reporter_user_id_idx", + "columns": [ + { + "expression": "reporter_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reports_status_idx": { + "name": "reports_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "reports_reporter_user_id_users_id_fk": { + "name": "reports_reporter_user_id_users_id_fk", + "tableFrom": "reports", + "tableTo": "users", + "columnsFrom": ["reporter_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "reports_reported_user_id_users_id_fk": { + "name": "reports_reported_user_id_users_id_fk", + "tableFrom": "reports", + "tableTo": "users", + "columnsFrom": ["reported_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "reports_reported_profile_id_profiles_id_fk": { + "name": "reports_reported_profile_id_profiles_id_fk", + "tableFrom": "reports", + "tableTo": "profiles", + "columnsFrom": ["reported_profile_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "reports_details_length_check": { + "name": "reports_details_length_check", + "value": "\"reports\".\"details\" is null or char_length(\"reports\".\"details\") <= 1000" + }, + "reports_no_self_report_check": { + "name": "reports_no_self_report_check", + "value": "\"reports\".\"reporter_user_id\" <> \"reports\".\"reported_user_id\"" + } + }, + "isRLSEnabled": false + }, + "public.saved_profiles": { + "name": "saved_profiles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "profile_id": { + "name": "profile_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "saved_profiles_user_profile_idx": { + "name": "saved_profiles_user_profile_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "profile_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "saved_profiles_user_id_idx": { + "name": "saved_profiles_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "saved_profiles_profile_id_idx": { + "name": "saved_profiles_profile_id_idx", + "columns": [ + { + "expression": "profile_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "saved_profiles_user_id_users_id_fk": { + "name": "saved_profiles_user_id_users_id_fk", + "tableFrom": "saved_profiles", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "saved_profiles_profile_id_profiles_id_fk": { + "name": "saved_profiles_profile_id_profiles_id_fk", + "tableFrom": "saved_profiles", + "tableTo": "profiles", + "columnsFrom": ["profile_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.suspicious_activity": { + "name": "suspicious_activity", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "action": { + "name": "action", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "ip": { + "name": "ip", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "suspicious_activity_created_at_idx": { + "name": "suspicious_activity_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "suspicious_activity_user_id_idx": { + "name": "suspicious_activity_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "suspicious_activity_user_id_users_id_fk": { + "name": "suspicious_activity_user_id_users_id_fk", + "tableFrom": "suspicious_activity", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_blocks": { + "name": "user_blocks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "blocker_user_id": { + "name": "blocker_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "blocked_user_id": { + "name": "blocked_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_blocks_blocker_blocked_idx": { + "name": "user_blocks_blocker_blocked_idx", + "columns": [ + { + "expression": "blocker_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "blocked_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_blocks_blocker_user_id_idx": { + "name": "user_blocks_blocker_user_id_idx", + "columns": [ + { + "expression": "blocker_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_blocks_blocked_user_id_idx": { + "name": "user_blocks_blocked_user_id_idx", + "columns": [ + { + "expression": "blocked_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_blocks_blocker_user_id_users_id_fk": { + "name": "user_blocks_blocker_user_id_users_id_fk", + "tableFrom": "user_blocks", + "tableTo": "users", + "columnsFrom": ["blocker_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_blocks_blocked_user_id_users_id_fk": { + "name": "user_blocks_blocked_user_id_users_id_fk", + "tableFrom": "user_blocks", + "tableTo": "users", + "columnsFrom": ["blocked_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "user_blocks_no_self_block_check": { + "name": "user_blocks_no_self_block_check", + "value": "\"user_blocks\".\"blocker_user_id\" <> \"user_blocks\".\"blocked_user_id\"" + } + }, + "isRLSEnabled": false + }, + "public.user_settings": { + "name": "user_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "theme": { + "name": "theme", + "type": "theme", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'dark'" + }, + "application_language": { + "name": "application_language", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "default": "'en'" + }, + "time_format": { + "name": "time_format", + "type": "time_format", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'24hr'" + }, + "activity_status": { + "name": "activity_status", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "push_notifications": { + "name": "push_notifications", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "match_alert": { + "name": "match_alert", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "profile_interaction_alert": { + "name": "profile_interaction_alert", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "profile_view_alert": { + "name": "profile_view_alert", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "product_analytics": { + "name": "product_analytics", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_settings_user_id_idx": { + "name": "user_settings_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_settings_user_id_users_id_fk": { + "name": "user_settings_user_id_users_id_fk", + "tableFrom": "user_settings", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "discord_user_id": { + "name": "discord_user_id", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "discord_username": { + "name": "discord_username", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_discord_user_id_idx": { + "name": "users_discord_user_id_idx", + "columns": [ + { + "expression": "discord_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.notification_kind": { + "name": "notification_kind", + "schema": "public", + "values": ["copy", "view"] + }, + "public.proficiency_level": { + "name": "proficiency_level", + "schema": "public", + "values": ["beginner", "intermediate", "advanced", "native-level"] + }, + "public.report_reason": { + "name": "report_reason", + "schema": "public", + "values": [ + "spam", + "harassment", + "inappropriate", + "impersonation", + "other" + ] + }, + "public.report_status": { + "name": "report_status", + "schema": "public", + "values": ["pending", "reviewed", "dismissed"] + }, + "public.theme": { + "name": "theme", + "schema": "public", + "values": ["dark", "light"] + }, + "public.time_format": { + "name": "time_format", + "schema": "public", + "values": ["12hr", "24hr"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 0fde61d..59d6f2f 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -78,6 +78,13 @@ "when": 1782835712871, "tag": "0010_curvy_shard", "breakpoints": true + }, + { + "idx": 11, + "version": "7", + "when": 1783130932159, + "tag": "0011_cute_thor", + "breakpoints": true } ] } diff --git a/src/app/[lang]/u/[id]/PublicProfileClient.tsx b/src/app/[lang]/u/[id]/PublicProfileClient.tsx index 7730aaf..b27b5f6 100644 --- a/src/app/[lang]/u/[id]/PublicProfileClient.tsx +++ b/src/app/[lang]/u/[id]/PublicProfileClient.tsx @@ -12,6 +12,7 @@ import { import { ReportDialog } from '@/features/Discovery/ReportDialog'; import { blockProfileRequest, + ReportProfileError, type ReportReason, reportProfileRequest, } from '@/features/Discovery/safetyRequests'; @@ -103,9 +104,13 @@ export const PublicProfileClient = ({ duration: TOAST_DURATION, }); } catch (error) { + const limited = + error instanceof ReportProfileError && error.status === 429; addToast({ - title: t('reportErrorTitle'), - description: t('reportErrorDescription'), + title: limited ? t('reportCooldownTitle') : t('reportErrorTitle'), + description: limited + ? t('reportCooldownDescription') + : t('reportErrorDescription'), duration: TOAST_DURATION, }); throw error; diff --git a/src/app/api/admin/suspicious-activity/route.ts b/src/app/api/admin/suspicious-activity/route.ts new file mode 100644 index 0000000..252acee --- /dev/null +++ b/src/app/api/admin/suspicious-activity/route.ts @@ -0,0 +1,24 @@ +import { NextResponse } from 'next/server'; +import { listSuspiciousActivity } from '@/db'; +import { isAdmin } from '@/lib/admin'; +import { getCurrentUser } from '@/lib/auth'; + +export const GET = async () => { + const currentUser = await getCurrentUser(); + + if (!currentUser || !isAdmin(currentUser)) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }); + } + + const rows = await listSuspiciousActivity(); + + return NextResponse.json({ + activity: rows.map((row) => ({ + id: row.id, + action: row.action, + userId: row.userId, + ip: row.ip, + createdAt: row.createdAt.toISOString(), + })), + }); +}; diff --git a/src/app/api/auth/discord/callback/route.ts b/src/app/api/auth/discord/callback/route.ts index a850e00..d75f063 100644 --- a/src/app/api/auth/discord/callback/route.ts +++ b/src/app/api/auth/discord/callback/route.ts @@ -10,6 +10,7 @@ import { readOAuthStateCookie, setSessionCookie, } from '@/lib/auth'; +import { enforceRateLimit, isRateLimited, requestIp } from '@/lib/rateLimit'; type DiscordTokenResponse = { access_token?: string; @@ -87,20 +88,34 @@ export const GET = async (request: NextRequest) => { const state = request.nextUrl.searchParams.get('state'); const storedState = await readOAuthStateCookie(); const redirectTo = storedState?.redirectTo ?? '/en'; + const ip = requestIp(request); + + const redirectWithFailure = async (failure: string) => { + const limit = await enforceRateLimit('auth-failure', { ip }); + return redirectWithError( + request, + redirectTo, + limit.allowed ? failure : 'oauth_rate_limited', + ); + }; if (error) { - return redirectWithError(request, redirectTo, 'oauth_cancelled'); + return redirectWithFailure('oauth_cancelled'); } if (!code || !state || state !== storedState?.nonce) { - return redirectWithError(request, redirectTo, 'oauth_invalid_state'); + return redirectWithFailure('oauth_invalid_state'); + } + + if (await isRateLimited('auth-failure', { ip })) { + return redirectWithError(request, redirectTo, 'oauth_rate_limited'); } try { const token = await exchangeCodeForToken(request, code); if (!token?.access_token) { - return redirectWithError(request, redirectTo, 'oauth_failed'); + return redirectWithFailure('oauth_failed'); } const discordUser = await fetchDiscordUser( @@ -109,7 +124,7 @@ export const GET = async (request: NextRequest) => { ); if (!discordUser) { - return redirectWithError(request, redirectTo, 'oauth_failed'); + return redirectWithFailure('oauth_failed'); } const currentUser = normalizeDiscordUser(discordUser); @@ -133,6 +148,6 @@ export const GET = async (request: NextRequest) => { return response; } catch { - return redirectWithError(request, redirectTo, 'oauth_failed'); + return redirectWithFailure('oauth_failed'); } }; diff --git a/src/app/api/notifications/route.ts b/src/app/api/notifications/route.ts index 4f6c241..f2911fa 100644 --- a/src/app/api/notifications/route.ts +++ b/src/app/api/notifications/route.ts @@ -11,6 +11,11 @@ import { upsertDiscordUser, } from '@/db'; import { getCurrentUser } from '@/lib/auth'; +import { + enforceRateLimit, + rateLimitedResponse, + requestIp, +} from '@/lib/rateLimit'; const parseBody = async ( request: Request, @@ -79,6 +84,15 @@ export const POST = async (request: Request) => { return NextResponse.json({ created: false }); } + const limit = await enforceRateLimit('copy', { + userId: actor.id, + ip: requestIp(request), + }); + + if (!limit.allowed) { + return rateLimitedResponse(limit.retryAfterMs); + } + await createNotification({ userId: target.profile.userId, kind: 'copy', diff --git a/src/app/api/profile/bump/route.ts b/src/app/api/profile/bump/route.ts index 3aa35e4..a4f905b 100644 --- a/src/app/api/profile/bump/route.ts +++ b/src/app/api/profile/bump/route.ts @@ -10,6 +10,7 @@ import { localeFromRequest } from '@/lib/analytics/locale'; import { trackEvent } from '@/lib/analytics/track.server'; import { getCurrentUser } from '@/lib/auth'; import { hasPremiumEntitlement } from '@/lib/entitlements'; +import { enforceRateLimit, requestIp } from '@/lib/rateLimit'; export const POST = async (request: Request) => { const currentUser = await getCurrentUser(); @@ -19,6 +20,19 @@ export const POST = async (request: Request) => { } const user = await upsertDiscordUser(currentUser); + + const limit = await enforceRateLimit('bump', { + userId: user.id, + ip: requestIp(request), + }); + + if (!limit.allowed) { + return NextResponse.json( + { error: 'Too many requests', remainingMs: limit.retryAfterMs }, + { status: 429 }, + ); + } + const row = await getProfileByUserId(user.id); if (!row?.profile.isPublic) { diff --git a/src/app/api/report/route.ts b/src/app/api/report/route.ts index 05e88af..5bd804e 100644 --- a/src/app/api/report/route.ts +++ b/src/app/api/report/route.ts @@ -6,6 +6,11 @@ import { upsertDiscordUser, } from '@/db'; import { getCurrentUser } from '@/lib/auth'; +import { + enforceRateLimit, + rateLimitedResponse, + requestIp, +} from '@/lib/rateLimit'; const REPORT_REASONS: ReportReason[] = [ 'spam', @@ -87,6 +92,15 @@ export const POST = async (request: Request) => { ); } + const limit = await enforceRateLimit('report', { + userId: reporter.id, + ip: requestIp(request), + }); + + if (!limit.allowed) { + return rateLimitedResponse(limit.retryAfterMs); + } + await createReport({ reporterUserId: reporter.id, reportedUserId: target.profile.userId, diff --git a/src/db/index.ts b/src/db/index.ts index e192367..57a8303 100644 --- a/src/db/index.ts +++ b/src/db/index.ts @@ -2,6 +2,7 @@ export * from './account'; export * from './analytics'; export * from './notifications'; export * from './profiles'; +export * from './rateLimits'; export * from './safety'; export * from './saved'; export * from './schema'; diff --git a/src/db/rateLimits.ts b/src/db/rateLimits.ts new file mode 100644 index 0000000..0ab01cc --- /dev/null +++ b/src/db/rateLimits.ts @@ -0,0 +1,87 @@ +import 'server-only'; + +import { and, desc, eq, sql } from 'drizzle-orm'; +import { db } from './client'; +import { + type NewSuspiciousActivity, + rateLimitCounters, + suspiciousActivity, +} from './schema'; + +export type RateLimitResult = { + allowed: boolean; + retryAfterMs: number; +}; + +const retryAfter = (windowStart: Date, windowMs: number, now: Date) => + Math.max(0, windowStart.getTime() + windowMs - now.getTime()); + +export const consumeRateLimit = async ( + scope: string, + subject: string, + max: number, + windowMs: number, +): Promise => { + const now = new Date(); + const cutoff = new Date(now.getTime() - windowMs).toISOString(); + const nowIso = now.toISOString(); + + const [row] = await db + .insert(rateLimitCounters) + .values({ scope, subject, windowStart: now, count: 1 }) + .onConflictDoUpdate({ + target: [rateLimitCounters.scope, rateLimitCounters.subject], + set: { + count: sql`case when ${rateLimitCounters.windowStart} <= ${cutoff} then 1 else ${rateLimitCounters.count} + 1 end`, + windowStart: sql`case when ${rateLimitCounters.windowStart} <= ${cutoff} then ${nowIso} else ${rateLimitCounters.windowStart} end`, + }, + }) + .returning(); + + return { + allowed: row.count <= max, + retryAfterMs: + row.count <= max ? 0 : retryAfter(row.windowStart, windowMs, now), + }; +}; + +export const peekRateLimit = async ( + scope: string, + subject: string, + max: number, + windowMs: number, +): Promise => { + const now = new Date(); + const [row] = await db + .select() + .from(rateLimitCounters) + .where( + and( + eq(rateLimitCounters.scope, scope), + eq(rateLimitCounters.subject, subject), + ), + ); + + if (!row || row.windowStart.getTime() <= now.getTime() - windowMs) { + return { allowed: true, retryAfterMs: 0 }; + } + + return { + allowed: row.count < max, + retryAfterMs: + row.count < max ? 0 : retryAfter(row.windowStart, windowMs, now), + }; +}; + +export const logSuspiciousActivity = async ( + entry: NewSuspiciousActivity, +): Promise => { + await db.insert(suspiciousActivity).values(entry); +}; + +export const listSuspiciousActivity = async (limit = 100) => + db + .select() + .from(suspiciousActivity) + .orderBy(desc(suspiciousActivity.createdAt)) + .limit(limit); diff --git a/src/db/schema.ts b/src/db/schema.ts index 0ad7abb..9a23a17 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -7,6 +7,7 @@ import { jsonb, pgEnum, pgTable, + primaryKey, text, timestamp, uniqueIndex, @@ -325,6 +326,36 @@ export const userBlocks = pgTable( ], ); +export const rateLimitCounters = pgTable( + 'rate_limit_counters', + { + scope: varchar('scope', { length: 32 }).notNull(), + subject: varchar('subject', { length: 128 }).notNull(), + windowStart: timestamp('window_start', { withTimezone: true }).notNull(), + count: integer('count').notNull(), + }, + (table) => [primaryKey({ columns: [table.scope, table.subject] })], +); + +export const suspiciousActivity = pgTable( + 'suspicious_activity', + { + id: uuid('id').defaultRandom().primaryKey(), + action: varchar('action', { length: 32 }).notNull(), + userId: uuid('user_id').references(() => users.id, { + onDelete: 'set null', + }), + ip: varchar('ip', { length: 64 }), + createdAt: timestamp('created_at', { withTimezone: true }) + .defaultNow() + .notNull(), + }, + (table) => [ + index('suspicious_activity_created_at_idx').on(table.createdAt), + index('suspicious_activity_user_id_idx').on(table.userId), + ], +); + export const usersRelations = relations(users, ({ many, one }) => ({ profile: one(profiles), savedProfiles: many(savedProfiles), @@ -397,6 +428,16 @@ export const reportsRelations = relations(reports, ({ one }) => ({ }), })); +export const suspiciousActivityRelations = relations( + suspiciousActivity, + ({ one }) => ({ + user: one(users, { + fields: [suspiciousActivity.userId], + references: [users.id], + }), + }), +); + export const userBlocksRelations = relations(userBlocks, ({ one }) => ({ blocker: one(users, { fields: [userBlocks.blockerUserId], @@ -430,3 +471,6 @@ export type NewReport = typeof reports.$inferInsert; export type ReportReason = (typeof reportReasonEnum.enumValues)[number]; export type UserBlock = typeof userBlocks.$inferSelect; export type NewUserBlock = typeof userBlocks.$inferInsert; +export type RateLimitCounter = typeof rateLimitCounters.$inferSelect; +export type SuspiciousActivity = typeof suspiciousActivity.$inferSelect; +export type NewSuspiciousActivity = typeof suspiciousActivity.$inferInsert; diff --git a/src/features/Discovery/DiscoveryPage.tsx b/src/features/Discovery/DiscoveryPage.tsx index 1f595a4..50f2477 100644 --- a/src/features/Discovery/DiscoveryPage.tsx +++ b/src/features/Discovery/DiscoveryPage.tsx @@ -44,6 +44,7 @@ import { SearchBar } from './SearchBar'; import { SortMenu } from './SortMenu'; import { blockProfileRequest, + ReportProfileError, type ReportReason, reportProfileRequest, } from './safetyRequests'; @@ -370,9 +371,13 @@ export const DiscoveryPage = ({ duration: BUMP_TOAST_DURATION, }); } catch (error) { + const limited = + error instanceof ReportProfileError && error.status === 429; addToast({ - title: t('reportErrorTitle'), - description: t('reportErrorDescription'), + title: limited ? t('reportCooldownTitle') : t('reportErrorTitle'), + description: limited + ? t('reportCooldownDescription') + : t('reportErrorDescription'), duration: BUMP_TOAST_DURATION, }); throw error; diff --git a/src/features/Discovery/safetyRequests.ts b/src/features/Discovery/safetyRequests.ts index 73c9500..61338ab 100644 --- a/src/features/Discovery/safetyRequests.ts +++ b/src/features/Discovery/safetyRequests.ts @@ -5,6 +5,15 @@ export type ReportReason = | 'impersonation' | 'other'; +export class ReportProfileError extends Error { + constructor( + message: string, + readonly status: number, + ) { + super(message); + } +} + export const reportProfileRequest = async ( profileId: string, reason: ReportReason, @@ -17,7 +26,7 @@ export const reportProfileRequest = async ( }); if (!response.ok) { - throw new Error('Report submission failed'); + throw new ReportProfileError('Report submission failed', response.status); } }; diff --git a/src/lib/rateLimit.ts b/src/lib/rateLimit.ts new file mode 100644 index 0000000..4e3806c --- /dev/null +++ b/src/lib/rateLimit.ts @@ -0,0 +1,93 @@ +import 'server-only'; + +import { NextResponse } from 'next/server'; +import { + consumeRateLimit, + logSuspiciousActivity, + peekRateLimit, + type RateLimitResult, +} from '@/db'; + +export type RateLimitAction = 'report' | 'bump' | 'copy' | 'auth-failure'; + +export type RateLimitSubject = { + userId?: string; + ip?: string | null; +}; + +const DEFAULT_LIMITS: Record< + RateLimitAction, + { max: number; windowMs: number } +> = { + report: { max: 5, windowMs: 3_600_000 }, + bump: { max: 10, windowMs: 60_000 }, + copy: { max: 30, windowMs: 3_600_000 }, + 'auth-failure': { max: 10, windowMs: 900_000 }, +}; + +export const getRateLimit = (action: RateLimitAction) => { + const raw = + process.env[ + `POLYCORD_RATE_LIMIT_${action.replace('-', '_').toUpperCase()}` + ]; + const match = raw?.match(/^(\d+):(\d+)$/); + + if (!match) { + return DEFAULT_LIMITS[action]; + } + + return { max: Number(match[1]), windowMs: Number(match[2]) * 1000 }; +}; + +const subjectKey = (subject: RateLimitSubject) => + subject.userId ? `user:${subject.userId}` : `ip:${subject.ip ?? 'unknown'}`; + +export const enforceRateLimit = async ( + action: RateLimitAction, + subject: RateLimitSubject, +): Promise => { + const { max, windowMs } = getRateLimit(action); + const result = await consumeRateLimit( + action, + subjectKey(subject), + max, + windowMs, + ); + + if (!result.allowed) { + await logSuspiciousActivity({ + action, + userId: subject.userId ?? null, + ip: subject.ip ?? null, + }); + } + + return result; +}; + +export const isRateLimited = async ( + action: RateLimitAction, + subject: RateLimitSubject, +): Promise => { + const { max, windowMs } = getRateLimit(action); + const result = await peekRateLimit( + action, + subjectKey(subject), + max, + windowMs, + ); + + return !result.allowed; +}; + +export const requestIp = (request: Request) => + request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || null; + +export const rateLimitedResponse = (retryAfterMs: number) => + NextResponse.json( + { error: 'Too many requests', retryAfterMs }, + { + status: 429, + headers: { 'Retry-After': String(Math.ceil(retryAfterMs / 1000)) }, + }, + ); diff --git a/src/locales/en.json b/src/locales/en.json index 4691e03..4491a09 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -441,6 +441,8 @@ "reportSuccessDescription": "Thanks for helping keep Polycord safe. Our team will review it.", "reportErrorTitle": "Couldn't submit report", "reportErrorDescription": "Something went wrong. Please try again.", + "reportCooldownTitle": "Too many reports", + "reportCooldownDescription": "You've reached the report limit. Please try again later.", "blockLoginTitle": "Log in to block users", "blockLoginDescription": "Sign in with Discord to hide profiles from your discovery feed.", "blockSuccessTitle": "User blocked", diff --git a/src/locales/ja.json b/src/locales/ja.json index 882d078..cd32779 100644 --- a/src/locales/ja.json +++ b/src/locales/ja.json @@ -441,6 +441,8 @@ "reportSuccessDescription": "Polycordの安全維持にご協力いただきありがとうございます。チームが確認します。", "reportErrorTitle": "報告を送信できませんでした", "reportErrorDescription": "問題が発生しました。もう一度お試しください。", + "reportCooldownTitle": "報告が多すぎます", + "reportCooldownDescription": "報告の上限に達しました。しばらくしてからもう一度お試しください。", "blockLoginTitle": "ログインしてブロック", "blockLoginDescription": "Discordでログインすると、プロフィールをディスカバリーから非表示にできます。", "blockSuccessTitle": "ユーザーをブロックしました",