Skip to content

Commit 3c7750a

Browse files
authored
Merge branch 'main' into chore(webapp)-add-200-and-500-percent-billing-alerts
2 parents 8259739 + dae84a0 commit 3c7750a

File tree

13 files changed

+308
-49
lines changed

13 files changed

+308
-49
lines changed

apps/webapp/app/env.server.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ const EnvironmentSchema = z
5959
ADMIN_EMAILS: z.string().refine(isValidRegex, "ADMIN_EMAILS must be a valid regex.").optional(),
6060
REMIX_APP_PORT: z.string().optional(),
6161
LOGIN_ORIGIN: z.string().default("http://localhost:3030"),
62+
LOGIN_RATE_LIMITS_ENABLED: BoolEnv.default(true),
6263
APP_ORIGIN: z.string().default("http://localhost:3030"),
6364
API_ORIGIN: z.string().optional(),
6465
STREAM_ORIGIN: z.string().optional(),

apps/webapp/app/root.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,13 @@ export const links: LinksFunction = () => {
2020
return [{ rel: "stylesheet", href: tailwindStylesheetUrl }];
2121
};
2222

23+
export const headers = () => ({
24+
"Referrer-Policy": "strict-origin-when-cross-origin",
25+
"X-Content-Type-Options": "nosniff",
26+
"Permissions-Policy":
27+
"geolocation=(), microphone=(), camera=(), accelerometer=(), gyroscope=(), magnetometer=(), payment=(), usb=()",
28+
});
29+
2330
export const meta: MetaFunction = ({ data }) => {
2431
const typedData = data as UseDataFunctionReturn<typeof loader>;
2532
return [

apps/webapp/app/routes/login.magic/route.tsx

Lines changed: 102 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
import { ArrowLeftIcon, EnvelopeIcon } from "@heroicons/react/20/solid";
22
import { InboxArrowDownIcon } from "@heroicons/react/24/solid";
3-
import type { ActionFunctionArgs, LoaderFunctionArgs, MetaFunction } from "@remix-run/node";
4-
import { redirect } from "@remix-run/node";
3+
import {
4+
redirect,
5+
type ActionFunctionArgs,
6+
type LoaderFunctionArgs,
7+
type MetaFunction,
8+
} from "@remix-run/node";
59
import { Form, useNavigation } from "@remix-run/react";
610
import { typedjson, useTypedLoaderData } from "remix-typedjson";
711
import { z } from "zod";
@@ -18,6 +22,14 @@ import { Spinner } from "~/components/primitives/Spinner";
1822
import { TextLink } from "~/components/primitives/TextLink";
1923
import { authenticator } from "~/services/auth.server";
2024
import { commitSession, getUserSession } from "~/services/sessionStorage.server";
25+
import {
26+
checkMagicLinkEmailRateLimit,
27+
checkMagicLinkEmailDailyRateLimit,
28+
MagicLinkRateLimitError,
29+
checkMagicLinkIpRateLimit,
30+
} from "~/services/magicLinkRateLimiter.server";
31+
import { logger, tryCatch } from "@trigger.dev/core/v3";
32+
import { env } from "~/env.server";
2133

2234
export const meta: MetaFunction = ({ matches }) => {
2335
const parentMeta = matches
@@ -71,29 +83,99 @@ export async function action({ request }: ActionFunctionArgs) {
7183

7284
const payload = Object.fromEntries(await clonedRequest.formData());
7385

74-
const { action } = z
75-
.object({
76-
action: z.enum(["send", "reset"]),
77-
})
86+
const data = z
87+
.discriminatedUnion("action", [
88+
z.object({
89+
action: z.literal("send"),
90+
email: z.string().trim().toLowerCase(),
91+
}),
92+
z.object({
93+
action: z.literal("reset"),
94+
}),
95+
])
7896
.parse(payload);
7997

80-
if (action === "send") {
81-
return authenticator.authenticate("email-link", request, {
82-
successRedirect: "/login/magic",
83-
failureRedirect: "/login/magic",
84-
});
85-
} else {
86-
const session = await getUserSession(request);
87-
session.unset("triggerdotdev:magiclink");
88-
89-
return redirect("/login/magic", {
90-
headers: {
91-
"Set-Cookie": await commitSession(session),
92-
},
93-
});
98+
switch (data.action) {
99+
case "send": {
100+
if (!env.LOGIN_RATE_LIMITS_ENABLED) {
101+
return authenticator.authenticate("email-link", request, {
102+
successRedirect: "/login/magic",
103+
failureRedirect: "/login/magic",
104+
});
105+
}
106+
107+
const { email } = data;
108+
const xff = request.headers.get("x-forwarded-for");
109+
const clientIp = extractClientIp(xff);
110+
111+
const [error] = await tryCatch(
112+
Promise.all([
113+
clientIp ? checkMagicLinkIpRateLimit(clientIp) : Promise.resolve(),
114+
checkMagicLinkEmailRateLimit(email),
115+
checkMagicLinkEmailDailyRateLimit(email),
116+
])
117+
);
118+
119+
if (error) {
120+
if (error instanceof MagicLinkRateLimitError) {
121+
logger.warn("Login magic link rate limit exceeded", {
122+
clientIp,
123+
email,
124+
error,
125+
});
126+
} else {
127+
logger.error("Failed sending login magic link", {
128+
clientIp,
129+
email,
130+
error,
131+
});
132+
}
133+
134+
const errorMessage =
135+
error instanceof MagicLinkRateLimitError
136+
? "Too many magic link requests. Please try again shortly."
137+
: "Failed sending magic link. Please try again shortly.";
138+
139+
const session = await getUserSession(request);
140+
session.set("auth:error", {
141+
message: errorMessage,
142+
});
143+
144+
return redirect("/login/magic", {
145+
headers: {
146+
"Set-Cookie": await commitSession(session),
147+
},
148+
});
149+
}
150+
151+
return authenticator.authenticate("email-link", request, {
152+
successRedirect: "/login/magic",
153+
failureRedirect: "/login/magic",
154+
});
155+
}
156+
case "reset":
157+
default: {
158+
data.action satisfies "reset";
159+
160+
const session = await getUserSession(request);
161+
session.unset("triggerdotdev:magiclink");
162+
163+
return redirect("/login/magic", {
164+
headers: {
165+
"Set-Cookie": await commitSession(session),
166+
},
167+
});
168+
}
94169
}
95170
}
96171

172+
const extractClientIp = (xff: string | null) => {
173+
if (!xff) return null;
174+
175+
const parts = xff.split(",").map((p) => p.trim());
176+
return parts[parts.length - 1]; // take last item, ALB appends the real client IP by default
177+
};
178+
97179
export default function LoginMagicLinkPage() {
98180
const { magicLinkSent, magicLinkError } = useTypedLoaderData<typeof loader>();
99181
const navigate = useNavigation();
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { Ratelimit } from "@upstash/ratelimit";
2+
import { env } from "~/env.server";
3+
import { createRedisRateLimitClient, RateLimiter } from "~/services/rateLimiter.server";
4+
import { singleton } from "~/utils/singleton";
5+
6+
export class MagicLinkRateLimitError extends Error {
7+
public readonly retryAfter: number;
8+
9+
constructor(retryAfter: number) {
10+
super("Magic link request rate limit exceeded.");
11+
this.retryAfter = retryAfter;
12+
}
13+
}
14+
15+
function getRedisClient() {
16+
return createRedisRateLimitClient({
17+
port: env.RATE_LIMIT_REDIS_PORT,
18+
host: env.RATE_LIMIT_REDIS_HOST,
19+
username: env.RATE_LIMIT_REDIS_USERNAME,
20+
password: env.RATE_LIMIT_REDIS_PASSWORD,
21+
tlsDisabled: env.RATE_LIMIT_REDIS_TLS_DISABLED === "true",
22+
clusterMode: env.RATE_LIMIT_REDIS_CLUSTER_MODE_ENABLED === "1",
23+
});
24+
}
25+
26+
const magicLinkEmailRateLimiter = singleton(
27+
"magicLinkEmailRateLimiter",
28+
initializeMagicLinkEmailRateLimiter
29+
);
30+
31+
function initializeMagicLinkEmailRateLimiter() {
32+
return new RateLimiter({
33+
redisClient: getRedisClient(),
34+
keyPrefix: "auth:magiclink:email",
35+
limiter: Ratelimit.slidingWindow(3, "1 m"), // 3 requests per minute per email
36+
logSuccess: false,
37+
logFailure: true,
38+
});
39+
}
40+
41+
const magicLinkEmailDailyRateLimiter = singleton(
42+
"magicLinkEmailDailyRateLimiter",
43+
initializeMagicLinkEmailDailyRateLimiter
44+
);
45+
46+
function initializeMagicLinkEmailDailyRateLimiter() {
47+
return new RateLimiter({
48+
redisClient: getRedisClient(),
49+
keyPrefix: "auth:magiclink:email:daily",
50+
limiter: Ratelimit.slidingWindow(30, "1 d"), // 30 requests per day per email
51+
logSuccess: false,
52+
logFailure: true,
53+
});
54+
}
55+
56+
const magicLinkIpRateLimiter = singleton(
57+
"magicLinkIpRateLimiter",
58+
initializeMagicLinkIpRateLimiter
59+
);
60+
61+
function initializeMagicLinkIpRateLimiter() {
62+
return new RateLimiter({
63+
redisClient: getRedisClient(),
64+
keyPrefix: "auth:magiclink:ip",
65+
limiter: Ratelimit.slidingWindow(10, "1 m"), // 10 requests per minute per IP
66+
logSuccess: false,
67+
logFailure: true,
68+
});
69+
}
70+
71+
export async function checkMagicLinkEmailRateLimit(identifier: string): Promise<void> {
72+
const result = await magicLinkEmailRateLimiter.limit(identifier);
73+
74+
if (!result.success) {
75+
const retryAfter = new Date(result.reset).getTime() - Date.now();
76+
throw new MagicLinkRateLimitError(retryAfter);
77+
}
78+
}
79+
80+
export async function checkMagicLinkEmailDailyRateLimit(identifier: string): Promise<void> {
81+
const result = await magicLinkEmailDailyRateLimiter.limit(identifier);
82+
83+
if (!result.success) {
84+
const retryAfter = new Date(result.reset).getTime() - Date.now();
85+
throw new MagicLinkRateLimitError(retryAfter);
86+
}
87+
}
88+
89+
export async function checkMagicLinkIpRateLimit(ip: string): Promise<void> {
90+
const result = await magicLinkIpRateLimiter.limit(ip);
91+
92+
if (!result.success) {
93+
const retryAfter = new Date(result.reset).getTime() - Date.now();
94+
throw new MagicLinkRateLimitError(retryAfter);
95+
}
96+
}

docker/dev-compose.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ services:
4848
- db
4949

5050
clickhouse:
51-
image: bitnami/clickhouse:latest
51+
image: bitnamilegacy/clickhouse:latest
5252
container_name: clickhouse-dev
5353
environment:
5454
CLICKHOUSE_ADMIN_USER: default

docker/docker-compose.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ services:
7676
- database
7777

7878
clickhouse:
79-
image: bitnami/clickhouse:latest
79+
image: bitnamilegacy/clickhouse:latest
8080
restart: always
8181
container_name: clickhouse
8282
environment:

docs/idempotency.mdx

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,6 @@ description: "An API call or operation is “idempotent” if it has the same re
55

66
We currently support idempotency at the task level, meaning that if you trigger a task with the same `idempotencyKey` twice, the second request will not create a new task run.
77

8-
<Warning>
9-
In version 3.3.0 and later, the `idempotencyKey` option is not available when using
10-
`triggerAndWait` or `batchTriggerAndWait`, due to a bug that would sometimes cause the parent task
11-
to become stuck. We are working on a fix for this issue.
12-
</Warning>
138

149
## `idempotencyKey` option
1510

hosting/docker/webapp/docker-compose.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,7 @@ services:
139139
start_period: 10s
140140

141141
clickhouse:
142-
image: bitnami/clickhouse:${CLICKHOUSE_IMAGE_TAG:-latest}
142+
image: bitnamilegacy/clickhouse:${CLICKHOUSE_IMAGE_TAG:-latest}
143143
restart: ${RESTART_POLICY:-unless-stopped}
144144
logging: *logging-config
145145
ports:
@@ -183,7 +183,7 @@ services:
183183
start_period: 10s
184184

185185
minio:
186-
image: bitnami/minio:${MINIO_IMAGE_TAG:-latest}
186+
image: bitnamilegacy/minio:${MINIO_IMAGE_TAG:-latest}
187187
restart: ${RESTART_POLICY:-unless-stopped}
188188
logging: *logging-config
189189
ports:

hosting/k8s/helm/Chart.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ apiVersion: v2
22
name: trigger
33
description: The official Trigger.dev Helm chart
44
type: application
5-
version: 4.0.1
5+
version: 4.0.3
66
appVersion: v4.0.4
77
home: https://trigger.dev
88
sources:

hosting/k8s/helm/templates/_helpers.tpl

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,34 @@ Get the full image name for supervisor
9595
{{- end }}
9696
{{- end }}
9797

98+
{{/*
99+
Get the full image name for webapp volumePermissions init container
100+
*/}}
101+
{{- define "trigger-v4.webapp.volumePermissions.image" -}}
102+
{{- $registry := .Values.global.imageRegistry | default .Values.webapp.volumePermissions.image.registry -}}
103+
{{- $repository := .Values.webapp.volumePermissions.image.repository -}}
104+
{{- $tag := .Values.webapp.volumePermissions.image.tag -}}
105+
{{- if $registry }}
106+
{{- printf "%s/%s:%s" $registry $repository $tag }}
107+
{{- else }}
108+
{{- printf "%s:%s" $repository $tag }}
109+
{{- end }}
110+
{{- end }}
111+
112+
{{/*
113+
Get the full image name for webapp tokenSyncer sidecar
114+
*/}}
115+
{{- define "trigger-v4.webapp.tokenSyncer.image" -}}
116+
{{- $registry := .Values.global.imageRegistry | default .Values.webapp.tokenSyncer.image.registry -}}
117+
{{- $repository := .Values.webapp.tokenSyncer.image.repository -}}
118+
{{- $tag := .Values.webapp.tokenSyncer.image.tag -}}
119+
{{- if $registry }}
120+
{{- printf "%s/%s:%s" $registry $repository $tag }}
121+
{{- else }}
122+
{{- printf "%s:%s" $repository $tag }}
123+
{{- end }}
124+
{{- end }}
125+
98126
{{/*
99127
PostgreSQL hostname (deprecated - used only for legacy DATABASE_HOST env var)
100128
*/}}

0 commit comments

Comments
 (0)