Skip to content

Commit 97c8a26

Browse files
committed
fix(feedback): rate limit feedback to 4 submissions per minute based on
feedbackId
1 parent 8ac0fd9 commit 97c8a26

File tree

2 files changed

+76
-19
lines changed

2 files changed

+76
-19
lines changed

app/_assets/javascripts/apps/Feedback.vue

Lines changed: 36 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,16 @@
1616
</button>
1717
</div>
1818

19-
<p v-if="vote !== null" class="feedback__reply text-sm text-terciary flex">
19+
<p v-if="vote !== null && !isRateLimited" class="feedback__reply text-sm text-terciary flex">
2020
Thank you! We received your feedback.
2121
</p>
2222

23+
<p v-if="isRateLimited" class="feedback__reply text-sm text-terciary flex">
24+
{{ rateLimitMessage }}
25+
</p>
26+
2327
<form
24-
v-if="vote === false"
28+
v-if="vote === false && !isRateLimited"
2529
class="flex flex-col gap-2 w-full"
2630
@submit.prevent="handleSubmit"
2731
>
@@ -49,30 +53,43 @@
4953
const message = ref('');
5054
const feedbackId = ref(null);
5155
const isSubmitting = ref(false);
56+
const isRateLimited = ref(false);
57+
const rateLimitMessage = ref('');
5258
53-
function handleVote(val) {
59+
async function handleVote(val) {
5460
if (isSubmitting.value) { return };
5561
5662
vote.value = val;
5763
isSubmitting.value = true;
5864
59-
fetch('/.netlify/functions/feedback-create', {
60-
method: 'POST',
61-
headers: { 'Content-Type': 'application/json' },
62-
body: JSON.stringify({
63-
pageUrl: window.location.href,
64-
feedbackId: feedbackId.value,
65-
vote: val
66-
})
67-
})
68-
.then((res) => res.json())
69-
.then((data) => {
70-
feedbackId.value ||= data.feedbackId;
71-
console.log('create callback')
72-
console.log(`id: ${feedbackId.value}`)
65+
66+
try {
67+
const res = await fetch('/.netlify/functions/feedback-create', {
68+
method: 'POST',
69+
headers: { 'Content-Type': 'application/json' },
70+
body: JSON.stringify({
71+
pageUrl: window.location.href,
72+
feedbackId: feedbackId.value,
73+
vote: val,
74+
}),
7375
})
74-
.catch((err) => console.error('Feedback error:', err))
75-
.finally(() => { isSubmitting.value = false; });
76+
77+
if (res.status === 429) {
78+
const data = await res.json()
79+
isRateLimited.value = true
80+
rateLimitMessage.value = data.error || 'Too many requests. Please wait.'
81+
return
82+
}
83+
84+
const data = await res.json()
85+
feedbackId.value ||= data.feedbackId
86+
isRateLimited.value = false
87+
rateLimitMessage.value = ''
88+
} catch (err) {
89+
console.error('Feedback error:', err)
90+
} finally {
91+
isSubmitting.value = false;
92+
}
7693
}
7794
7895
function handleSubmit() {

netlify/functions/feedback-create.mjs

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,26 @@ async function sendDataToSlack(url, vote, id, webhookUrl) {
2323
}
2424
}
2525

26+
const recentSubmissions = new Map();
27+
const RATE_LIMIT_MS = 60 * 1_000;
28+
const MAX_SUBMISSIONS = 4;
29+
30+
function isRateLimited(id, now) {
31+
for (const [submissionId, timestamps] of recentSubmissions) {
32+
const validTimestamps = timestamps.filter(
33+
(time) => now - time <= RATE_LIMIT_MS
34+
);
35+
if (validTimestamps.length === 0) {
36+
recentSubmissions.delete(submissionId);
37+
} else {
38+
recentSubmissions.set(submissionId, validTimestamps);
39+
}
40+
}
41+
42+
const timestamps = recentSubmissions.get(id) || [];
43+
return timestamps.length >= MAX_SUBMISSIONS;
44+
}
45+
2646
export async function handler(event, context) {
2747
const webhookUrl = process.env.SLACK_WEBHOOK_URL;
2848
let connection;
@@ -60,6 +80,26 @@ export async function handler(event, context) {
6080
id = feedbackId;
6181
}
6282

83+
const now = Date.now();
84+
if (isRateLimited(id, now)) {
85+
const timestamps = recentSubmissions.get(id) || [];
86+
const oldestSubmission = Math.min(...timestamps);
87+
const remaining = Math.ceil(
88+
(RATE_LIMIT_MS - (now - oldestSubmission)) / 1000
89+
);
90+
return {
91+
statusCode: 429,
92+
body: JSON.stringify({
93+
error: `Rate limit exceeded. Try again in ${remaining} seconds.`,
94+
}),
95+
headers: { "Content-Type": "application/json" },
96+
};
97+
}
98+
99+
const timestamps = recentSubmissions.get(id) || [];
100+
timestamps.push(now);
101+
recentSubmissions.set(id, timestamps);
102+
63103
const url = new URL(pageUrl);
64104
url.hash = "";
65105

0 commit comments

Comments
 (0)