feat(comments): 60s dedupe window + idempotency key
- Server window raised from 5s to 60s so slow double-fires (observed on /videos/CRKAf: same body saved 3-7 times, seconds to minutes apart) collapse into one comment. - X-Idempotency-Key honored: the client sends a per-attempt UUID; the server caches the resulting comment id for 5 minutes under that key. A retry with the same key returns the first-written comment without touching the DB a second time. - Comment and reply submit JS both send the key via fetch(). The existing _busy flag + disabled submit button already covers the fast double-click case on the client; this hardens the server side so duplicates can't slip through even when the client guard is bypassed. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
1 parent
3122420a3d
commit
c9123bc7b8
2 files changed
+51
-7
No files matched your search
@@ -36,10 +36,34 @@ class CommentController extends Controller
|
||||
|
||||
$commenter = Auth::user();
|
||||
|
||||
// Idempotency guard: a client that retries (duplicate listeners after an
|
||||
// SPA swap, fast double-click, a flaky connection) can POST the identical
|
||||
// body twice. Treat an identical comment from the same user to the same
|
||||
// video within 5 seconds as the same submission and return the first one.
|
||||
// Idempotency guard: a client that retries (duplicate listeners after
|
||||
// an SPA swap, fast double-click, flaky connection) can POST the same
|
||||
// comment twice. We dedupe on two signals:
|
||||
//
|
||||
// 1. X-Idempotency-Key — the fetch() client sends a per-attempt
|
||||
// UUID; the browser's own retry logic reuses the same value.
|
||||
// This catches the deliberate-retry case before body comparison.
|
||||
//
|
||||
// 2. Identical (user, video, parent, body) within 60s — covers
|
||||
// the duplicate-listener case where two independent fetch()es
|
||||
// fire with different idempotency keys for the same user intent.
|
||||
//
|
||||
// Either match returns the first-written comment unchanged.
|
||||
$idemKey = (string) $request->header('X-Idempotency-Key', '');
|
||||
$cacheKey = $idemKey !== ''
|
||||
? 'comment-idem:' . $commenter->id . ':' . $video->id . ':' . substr(hash('sha256', $idemKey), 0, 40)
|
||||
: null;
|
||||
|
||||
if ($cacheKey) {
|
||||
$existingId = \Illuminate\Support\Facades\Cache::get($cacheKey);
|
||||
if ($existingId && $existing = Comment::find($existingId)) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'comment' => $existing->load('user:id,name,avatar'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$existing = $video->comments()
|
||||
->where('user_id', $commenter->id)
|
||||
->where('body', $request->body)
|
||||
@@ -48,11 +72,14 @@ class CommentController extends Controller
|
||||
? $q->where('parent_id', $request->parent_id)
|
||||
: $q->whereNull('parent_id');
|
||||
})
|
||||
->where('created_at', '>=', now()->subSeconds(5))
|
||||
->where('created_at', '>=', now()->subSeconds(60))
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
if ($existing) {
|
||||
if ($cacheKey) {
|
||||
\Illuminate\Support\Facades\Cache::put($cacheKey, $existing->id, now()->addMinutes(5));
|
||||
}
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'comment' => $existing->load('user:id,name,avatar'),
|
||||
@@ -65,6 +92,10 @@ class CommentController extends Controller
|
||||
'parent_id' => $request->parent_id,
|
||||
]);
|
||||
|
||||
if ($cacheKey) {
|
||||
\Illuminate\Support\Facades\Cache::put($cacheKey, $comment->id, now()->addMinutes(5));
|
||||
}
|
||||
|
||||
$comment->load('user:id,name,avatar');
|
||||
|
||||
// Fire notifications (never notify yourself)
|
||||
|
||||
@@ -1135,9 +1135,17 @@ async function postComment() {
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = 'Posting…';
|
||||
try {
|
||||
// Idempotency key: random per attempt. On retry the client reuses the
|
||||
// same key so the server returns the original comment instead of
|
||||
// writing a second one.
|
||||
const idemKey = (window.crypto?.randomUUID?.() || (Date.now() + '-' + Math.random().toString(36).slice(2)));
|
||||
const r = await fetch('/videos/' + YTC.videoId + '/comments', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': YTC.csrf },
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': YTC.csrf,
|
||||
'X-Idempotency-Key': idemKey,
|
||||
},
|
||||
body: JSON.stringify({ body: text })
|
||||
});
|
||||
if (!(r.headers.get('content-type') || '').includes('application/json')) {
|
||||
@@ -1178,9 +1186,14 @@ async function postReply(vid, parentId) {
|
||||
btn.textContent = 'Posting…';
|
||||
}
|
||||
try {
|
||||
const idemKey = (window.crypto?.randomUUID?.() || (Date.now() + '-' + Math.random().toString(36).slice(2)));
|
||||
const r = await fetch('/videos/' + vid + '/comments', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': YTC.csrf },
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': YTC.csrf,
|
||||
'X-Idempotency-Key': idemKey,
|
||||
},
|
||||
body: JSON.stringify({ body: text, parent_id: parentId })
|
||||
});
|
||||
if (!(r.headers.get('content-type') || '').includes('application/json')) {
|
||||
|
||||
Reference in new issue
Block a user