fix(functions): add server-side throttle to gentle_reminders callable
This commit is contained in:
parent
71b230719b
commit
7898a4887f
|
|
@ -1,13 +1,21 @@
|
||||||
import * as functions from 'firebase-functions'
|
import * as functions from 'firebase-functions'
|
||||||
import * as admin from 'firebase-admin'
|
import * as admin from 'firebase-admin'
|
||||||
|
|
||||||
|
const GENTLE_REMINDER_MAX_PER_HOUR = 5
|
||||||
|
const GENTLE_REMINDER_WINDOW_MS = 60 * 60 * 1000 // 1 hour
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sends a gentle nudge from one partner to the other when the caller has
|
* Sends a gentle nudge from one partner to the other when the caller has
|
||||||
* already answered today's question but the partner hasn't.
|
* already answered today's question but the partner hasn't.
|
||||||
*
|
*
|
||||||
* Rate limit: one reminder per couple per calendar day (UTC). The lock is
|
* Rate limits:
|
||||||
* stored in couples/{coupleId}/gentle_reminders/{date} so it survives
|
* - Per-user: max 5 gentle reminders per rolling hour. Guarded by a server-side
|
||||||
* function restarts and is visible to both partners.
|
* transaction on `rate_limits/{uid}_gentle_reminder` so malicious clients
|
||||||
|
* cannot bypass it by calling the callable in a loop. The Android-side
|
||||||
|
* NotificationRateLimiter remains for UX but is not the authoritative guard.
|
||||||
|
* - Per-couple: one reminder per couple per calendar day (UTC). The lock is
|
||||||
|
* stored in couples/{coupleId}/gentle_reminders/{date} so it survives
|
||||||
|
* function restarts and is visible to both partners.
|
||||||
*
|
*
|
||||||
* The notification is both an FCM push (for the system tray) and an entry in
|
* The notification is both an FCM push (for the system tray) and an entry in
|
||||||
* the partner's notification_queue (for in-app display).
|
* the partner's notification_queue (for in-app display).
|
||||||
|
|
@ -39,7 +47,56 @@ export const sendGentleReminderCallable = functions.https.onCall(async (_data, c
|
||||||
throw new functions.https.HttpsError('failed-precondition', 'No partner found.')
|
throw new functions.https.HttpsError('failed-precondition', 'No partner found.')
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 2. Rate limit: one per couple per day ────────────────────────────────
|
// ── 2. Server-side per-user throttle: 5 per hour (rolling window) ────────
|
||||||
|
|
||||||
|
const now = admin.firestore.Timestamp.now()
|
||||||
|
const rateLimitRef = db.collection('rate_limits').doc(`${callerId}_gentle_reminder`)
|
||||||
|
|
||||||
|
const throttleResult = await db.runTransaction(async (tx) => {
|
||||||
|
const snap = await tx.get(rateLimitRef)
|
||||||
|
const data = snap.data()
|
||||||
|
|
||||||
|
let windowStart: admin.firestore.Timestamp
|
||||||
|
let count: number
|
||||||
|
|
||||||
|
if (!snap.exists || !data) {
|
||||||
|
windowStart = now
|
||||||
|
count = 0
|
||||||
|
} else {
|
||||||
|
windowStart = data.windowStart as admin.firestore.Timestamp
|
||||||
|
count = (typeof data.count === 'number' ? data.count : 0)
|
||||||
|
|
||||||
|
const elapsedMs = now.toMillis() - windowStart.toMillis()
|
||||||
|
if (elapsedMs >= GENTLE_REMINDER_WINDOW_MS) {
|
||||||
|
// Rolling window has expired; start a fresh one.
|
||||||
|
windowStart = now
|
||||||
|
count = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count >= GENTLE_REMINDER_MAX_PER_HOUR) {
|
||||||
|
const retryAfterMs = GENTLE_REMINDER_WINDOW_MS - (now.toMillis() - windowStart.toMillis())
|
||||||
|
const retryAfterMinutes = Math.max(1, Math.ceil(retryAfterMs / 60_000))
|
||||||
|
return { allowed: false, retryAfterMinutes }
|
||||||
|
}
|
||||||
|
|
||||||
|
tx.set(rateLimitRef, {
|
||||||
|
count: count + 1,
|
||||||
|
windowStart,
|
||||||
|
updatedAt: now,
|
||||||
|
}, { merge: true })
|
||||||
|
|
||||||
|
return { allowed: true, count: count + 1, windowStart }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!throttleResult.allowed) {
|
||||||
|
throw new functions.https.HttpsError(
|
||||||
|
'resource-exhausted',
|
||||||
|
`Too many gentle reminders. Try again in ${throttleResult.retryAfterMinutes} minutes.`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 3. Rate limit: one per couple per day ────────────────────────────────
|
||||||
|
|
||||||
const today = new Date().toISOString().slice(0, 10) // e.g. "2026-06-19"
|
const today = new Date().toISOString().slice(0, 10) // e.g. "2026-06-19"
|
||||||
const lockRef = db
|
const lockRef = db
|
||||||
|
|
@ -53,7 +110,7 @@ export const sendGentleReminderCallable = functions.https.onCall(async (_data, c
|
||||||
return { sent: false, reason: 'already_sent_today' }
|
return { sent: false, reason: 'already_sent_today' }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 3. Collect partner FCM tokens ────────────────────────────────────────
|
// ── 4. Collect partner FCM tokens ────────────────────────────────────────
|
||||||
|
|
||||||
const tokens: string[] = []
|
const tokens: string[] = []
|
||||||
const partnerDoc = await db.collection('users').doc(partnerId).get()
|
const partnerDoc = await db.collection('users').doc(partnerId).get()
|
||||||
|
|
@ -76,7 +133,7 @@ export const sendGentleReminderCallable = functions.https.onCall(async (_data, c
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// ── 4. Write in-app notification record ──────────────────────────────────
|
// ── 5. Write in-app notification record ──────────────────────────────────
|
||||||
|
|
||||||
await db
|
await db
|
||||||
.collection('users')
|
.collection('users')
|
||||||
|
|
@ -91,14 +148,14 @@ export const sendGentleReminderCallable = functions.https.onCall(async (_data, c
|
||||||
createdAt: admin.firestore.FieldValue.serverTimestamp(),
|
createdAt: admin.firestore.FieldValue.serverTimestamp(),
|
||||||
})
|
})
|
||||||
|
|
||||||
// ── 5. Claim the daily rate-limit lock ───────────────────────────────────
|
// ── 6. Claim the daily rate-limit lock ───────────────────────────────────
|
||||||
|
|
||||||
await lockRef.set({
|
await lockRef.set({
|
||||||
sentBy: callerId,
|
sentBy: callerId,
|
||||||
sentAt: admin.firestore.FieldValue.serverTimestamp(),
|
sentAt: admin.firestore.FieldValue.serverTimestamp(),
|
||||||
})
|
})
|
||||||
|
|
||||||
// ── 6. Send FCM push ─────────────────────────────────────────────────────
|
// ── 7. Send FCM push ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
if (tokens.length > 0) {
|
if (tokens.length > 0) {
|
||||||
const sendResults = await Promise.allSettled(
|
const sendResults = await Promise.allSettled(
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue