Skip to content

Commit 807f0f8

Browse files
author
AztecBot
committed
Merge branch 'next' into merge-train/avm
2 parents 89386a3 + 007f622 commit 807f0f8

File tree

11 files changed

+1528
-20
lines changed

11 files changed

+1528
-20
lines changed

docs/docusaurus.config.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -300,7 +300,7 @@ const config = {
300300
to: "/",
301301
},
302302
{
303-
label: "Developer Getting Started Guide",
303+
label: "Developer Getting Started",
304304
to: "/developers/getting_started",
305305
},
306306
{

docs/netlify.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
[build]
22
# Only build if changes are made in the docs directory
33
ignore = "git diff --quiet $CACHED_COMMIT_REF $COMMIT_REF"
4+
functions = "netlify/functions"
45

56
[[redirects]]
67
from = "/guides/smart_contracts/writing_contracts/initializers"
Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,245 @@
1+
// Netlify function for email subscription using Brevo API
2+
3+
const brevo = require('@getbrevo/brevo');
4+
5+
// Email validation function for serverless backend
6+
function isValidEmail(email) {
7+
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
8+
9+
if (!email || typeof email !== 'string') {
10+
return false;
11+
}
12+
13+
if (!emailRegex.test(email)) {
14+
return false;
15+
}
16+
17+
if (email.length > 254) {
18+
return false;
19+
}
20+
21+
const [localPart, domain] = email.split('@');
22+
if (localPart.length > 64 || domain.length > 253) {
23+
return false;
24+
}
25+
26+
return true;
27+
}
28+
29+
const BREVO_LIST_ID = 100;
30+
31+
// Simple in-memory rate limiting (resets on cold starts)
32+
const rateLimitMap = new Map();
33+
const RATE_LIMIT_WINDOW = 60 * 1000; // 1 minute
34+
const MAX_REQUESTS_PER_WINDOW = 5; // 5 requests per minute per IP
35+
const MAX_EMAIL_REQUESTS_PER_HOUR = 3; // 3 submissions per hour per email
36+
37+
// Cleanup old entries periodically
38+
setInterval(() => {
39+
const now = Date.now();
40+
for (const [key, data] of rateLimitMap.entries()) {
41+
if (now - data.firstRequest > RATE_LIMIT_WINDOW) {
42+
rateLimitMap.delete(key);
43+
}
44+
}
45+
}, RATE_LIMIT_WINDOW);
46+
47+
function isRateLimited(identifier, maxRequests = MAX_REQUESTS_PER_WINDOW) {
48+
const now = Date.now();
49+
const key = identifier;
50+
51+
if (!rateLimitMap.has(key)) {
52+
rateLimitMap.set(key, { count: 1, firstRequest: now });
53+
return false;
54+
}
55+
56+
const data = rateLimitMap.get(key);
57+
58+
// Reset if window expired
59+
if (now - data.firstRequest > RATE_LIMIT_WINDOW) {
60+
rateLimitMap.set(key, { count: 1, firstRequest: now });
61+
return false;
62+
}
63+
64+
// Check if over limit
65+
if (data.count >= maxRequests) {
66+
return true;
67+
}
68+
69+
// Increment counter
70+
data.count++;
71+
return false;
72+
}
73+
74+
exports.handler = async (event, context) => {
75+
// Only allow POST requests
76+
if (event.httpMethod !== 'POST') {
77+
return {
78+
statusCode: 405,
79+
headers: {
80+
'Content-Type': 'application/json',
81+
'Access-Control-Allow-Origin': '*',
82+
'Access-Control-Allow-Methods': 'POST',
83+
'Access-Control-Allow-Headers': 'Content-Type',
84+
},
85+
body: JSON.stringify({ error: 'Method not allowed' }),
86+
};
87+
}
88+
89+
// Get client IP for rate limiting
90+
const clientIP = event.headers['client-ip'] || event.headers['x-forwarded-for'] || 'unknown';
91+
92+
// Rate limit by IP
93+
if (isRateLimited(`ip:${clientIP}`)) {
94+
return {
95+
statusCode: 429,
96+
headers: {
97+
'Content-Type': 'application/json',
98+
'Access-Control-Allow-Origin': '*',
99+
'Retry-After': '60',
100+
},
101+
body: JSON.stringify({
102+
error: 'Too many requests. Please try again in a minute.',
103+
retryAfter: 60
104+
}),
105+
};
106+
}
107+
108+
try {
109+
// Basic request validation
110+
if (!event.body) {
111+
return {
112+
statusCode: 400,
113+
headers: {
114+
'Content-Type': 'application/json',
115+
'Access-Control-Allow-Origin': '*',
116+
},
117+
body: JSON.stringify({ error: 'Request body is required' }),
118+
};
119+
}
120+
121+
// Limit request body size (prevent large payload attacks)
122+
if (event.body.length > 1000) {
123+
return {
124+
statusCode: 413,
125+
headers: {
126+
'Content-Type': 'application/json',
127+
'Access-Control-Allow-Origin': '*',
128+
},
129+
body: JSON.stringify({ error: 'Request too large' }),
130+
};
131+
}
132+
133+
const { email, source } = JSON.parse(event.body);
134+
135+
if (!email || typeof email !== 'string' || !isValidEmail(email)) {
136+
return {
137+
statusCode: 400,
138+
headers: {
139+
'Content-Type': 'application/json',
140+
'Access-Control-Allow-Origin': '*',
141+
},
142+
body: JSON.stringify({ error: 'Please provide a valid email address' }),
143+
};
144+
}
145+
146+
// Normalize and sanitize email
147+
const normalizedEmail = email.toLowerCase().trim();
148+
149+
// Additional length check (RFC 5321 max email length)
150+
if (normalizedEmail.length > 254) {
151+
return {
152+
statusCode: 400,
153+
headers: {
154+
'Content-Type': 'application/json',
155+
'Access-Control-Allow-Origin': '*',
156+
},
157+
body: JSON.stringify({ error: 'Email address is too long' }),
158+
};
159+
}
160+
161+
// Rate limit by email (prevent spam submissions for same email)
162+
if (isRateLimited(`email:${normalizedEmail}`, MAX_EMAIL_REQUESTS_PER_HOUR)) {
163+
return {
164+
statusCode: 429,
165+
headers: {
166+
'Content-Type': 'application/json',
167+
'Access-Control-Allow-Origin': '*',
168+
'Retry-After': '3600',
169+
},
170+
body: JSON.stringify({
171+
error: 'This email has been submitted recently. Please try again later.',
172+
retryAfter: 3600
173+
}),
174+
};
175+
}
176+
177+
// Initialize Brevo API client
178+
const apiInstance = new brevo.ContactsApi();
179+
apiInstance.setApiKey(brevo.ContactsApiApiKeys.apiKey, process.env.BREVO_API_KEY);
180+
181+
try {
182+
// Create contact in Brevo
183+
const createContact = new brevo.CreateContact();
184+
createContact.email = normalizedEmail;
185+
createContact.listIds = [BREVO_LIST_ID];
186+
187+
// Add source tracking
188+
if (source) {
189+
createContact.attributes = { SOURCE: source };
190+
}
191+
192+
await apiInstance.createContact(createContact);
193+
194+
} catch (brevoError) {
195+
// Check if contact already exists (Brevo returns 409 for duplicates)
196+
if (brevoError && brevoError.status === 409) {
197+
// Contact exists, try to add to list
198+
try {
199+
const addToList = new brevo.AddContactToList();
200+
addToList.emails = [normalizedEmail];
201+
202+
await apiInstance.addContactToList(BREVO_LIST_ID, addToList);
203+
204+
} catch (listError) {
205+
console.error('Error adding existing contact to list:', listError);
206+
// Contact exists and might already be in the list
207+
return {
208+
statusCode: 200,
209+
headers: {
210+
'Content-Type': 'application/json',
211+
'Access-Control-Allow-Origin': '*',
212+
},
213+
body: JSON.stringify({ message: 'Email already subscribed' }),
214+
};
215+
}
216+
} else {
217+
// Other Brevo API error
218+
console.error('Brevo API error:', brevoError);
219+
throw brevoError;
220+
}
221+
}
222+
223+
return {
224+
statusCode: 200,
225+
headers: {
226+
'Content-Type': 'application/json',
227+
'Access-Control-Allow-Origin': '*',
228+
},
229+
body: JSON.stringify({
230+
message: 'Successfully subscribed!',
231+
email: normalizedEmail
232+
}),
233+
};
234+
} catch (error) {
235+
console.error('Subscription error:', error);
236+
return {
237+
statusCode: 500,
238+
headers: {
239+
'Content-Type': 'application/json',
240+
'Access-Control-Allow-Origin': '*',
241+
},
242+
body: JSON.stringify({ error: 'Failed to subscribe. Please try again.' }),
243+
};
244+
}
245+
};

docs/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
"@docusaurus/plugin-ideal-image": "3.8.1",
2020
"@docusaurus/preset-classic": "3.8.1",
2121
"@docusaurus/theme-mermaid": "3.8.1",
22+
"@getbrevo/brevo": "^3.0.1",
2223
"clsx": "^2.1.1",
2324
"docusaurus-theme-search-typesense": "0.25.0",
2425
"prism-react-renderer": "^2.4.1",

0 commit comments

Comments
 (0)