From 650694665de9e2d93306cdd392d0b2e2f801e197 Mon Sep 17 00:00:00 2001 From: giteaadmin Date: Wed, 27 Aug 2025 16:29:58 -0400 Subject: [PATCH] fix: like button race condition --- package-lock.json | 14 ++++++++++++++ package.json | 1 + src/js/like-button.js | 13 ++++++++++++- src/server.js | 14 ++++++++++++++ 4 files changed, 41 insertions(+), 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index bc85f44..15f31bd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@11ty/eleventy": "^2.0.1", "@11ty/eleventy-plugin-syntaxhighlight": "^5.0.2", + "async-mutex": "^0.5.0", "cors": "^2.8.5", "express": "^4.21.2", "express-rate-limit": "^8.0.1", @@ -394,6 +395,14 @@ "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==" }, + "node_modules/async-mutex": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/async-mutex/-/async-mutex-0.5.0.tgz", + "integrity": "sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==", + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/babel-walk": { "version": "3.0.0-canary-5", "resolved": "https://registry.npmjs.org/babel-walk/-/babel-walk-3.0.0-canary-5.tgz", @@ -2882,6 +2891,11 @@ "nodetouch": "bin/nodetouch.js" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" + }, "node_modules/type-is": { "version": "1.6.18", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", diff --git a/package.json b/package.json index b12cea9..f7254aa 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "dependencies": { "@11ty/eleventy": "^2.0.1", "@11ty/eleventy-plugin-syntaxhighlight": "^5.0.2", + "async-mutex": "^0.5.0", "cors": "^2.8.5", "express": "^4.21.2", "express-rate-limit": "^8.0.1", diff --git a/src/js/like-button.js b/src/js/like-button.js index bb6a93a..3a0a0f9 100644 --- a/src/js/like-button.js +++ b/src/js/like-button.js @@ -5,6 +5,7 @@ document.addEventListener('DOMContentLoaded', () => { const slug = likeIcon.dataset.slug; const likeCountSpan = document.querySelector(`[data-like-count][data-slug="${slug}"]`); const storageKey = `liked-${slug}`; + let isRequestInProgress = false; // Function to update the UI based on liked state and count const updateUI = (isLiked, count) => { @@ -48,6 +49,12 @@ document.addEventListener('DOMContentLoaded', () => { // Add click event listener to the icon likeIcon.addEventListener('click', async () => { + // If a request is already in progress, do nothing. + if (isRequestInProgress) { + return; + } + isRequestInProgress = true; + // Stop the hint animation if it's running likeIcon.classList.remove('hint-animation'); // Toggle the liked state @@ -56,7 +63,9 @@ document.addEventListener('DOMContentLoaded', () => { try { const response = await fetch(`/api/likes/${slug}`, { method }); - if (!response.ok) return; + if (!response.ok) { + throw new Error(`Server responded with ${response.status}`); + } const data = await response.json(); const isNowLiked = !isCurrentlyLiked; @@ -67,6 +76,8 @@ document.addEventListener('DOMContentLoaded', () => { } catch (error) { console.error('Error submitting like/unlike:', error); + } finally { + isRequestInProgress = false; } }); diff --git a/src/server.js b/src/server.js index 5b60883..29c57a7 100644 --- a/src/server.js +++ b/src/server.js @@ -3,10 +3,15 @@ const cors = require('cors'); const fs = require('fs-extra'); const path = require('path'); const rateLimit = require('express-rate-limit'); +const { Mutex } = require('async-mutex'); const app = express(); const port = 3000; +// Create mutexes to prevent race conditions when updating JSON files +const viewsMutex = new Mutex(); +const likesMutex = new Mutex(); + // trust proxy: // Used by express-rate-limit to obtain the client's IP address. // '1' means that the first hop (your reverse proxy) is trusted. @@ -97,6 +102,7 @@ app.get('/api/likes/:slug', validateSlug, async (req, res) => { // POST: Increment the view count for a specific post slug app.post('/api/views/:slug', validateSlug, async (req, res) => { + const release = await viewsMutex.acquire(); try { const { slug } = req.params; const views = await fs.readJson(dbPath); @@ -106,11 +112,14 @@ app.post('/api/views/:slug', validateSlug, async (req, res) => { } catch (error) { console.error('Error updating view count:', error); res.status(500).json({ error: 'Internal Server Error' }); + } finally { + release(); } }); // POST: Increment the like count for a specific post slug app.post('/api/likes/:slug', validateSlug, async (req, res) => { + const release = await likesMutex.acquire(); try { const { slug } = req.params; const likes = await fs.readJson(likesDbPath); @@ -120,11 +129,14 @@ app.post('/api/likes/:slug', validateSlug, async (req, res) => { } catch (error) { console.error('Error updating like count:', error); res.status(500).json({ error: 'Internal Server Error' }); + } finally { + release(); } }); // DELETE: Decrement the like count for a specific post slug (unlike) app.delete('/api/likes/:slug', validateSlug, async (req, res) => { + const release = await likesMutex.acquire(); try { const { slug } = req.params; const likes = await fs.readJson(likesDbPath); @@ -139,6 +151,8 @@ app.delete('/api/likes/:slug', validateSlug, async (req, res) => { } catch (error) { console.error('Error updating like count:', error); res.status(500).json({ error: 'Internal Server Error' }); + } finally { + release(); } });