Public Access
1
0

fix: like button race condition

This commit is contained in:
2025-08-27 16:29:58 -04:00
parent f68c94a18e
commit 650694665d
4 changed files with 41 additions and 1 deletions

14
package-lock.json generated
View File

@@ -11,6 +11,7 @@
"dependencies": { "dependencies": {
"@11ty/eleventy": "^2.0.1", "@11ty/eleventy": "^2.0.1",
"@11ty/eleventy-plugin-syntaxhighlight": "^5.0.2", "@11ty/eleventy-plugin-syntaxhighlight": "^5.0.2",
"async-mutex": "^0.5.0",
"cors": "^2.8.5", "cors": "^2.8.5",
"express": "^4.21.2", "express": "^4.21.2",
"express-rate-limit": "^8.0.1", "express-rate-limit": "^8.0.1",
@@ -394,6 +395,14 @@
"resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
"integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==" "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": { "node_modules/babel-walk": {
"version": "3.0.0-canary-5", "version": "3.0.0-canary-5",
"resolved": "https://registry.npmjs.org/babel-walk/-/babel-walk-3.0.0-canary-5.tgz", "resolved": "https://registry.npmjs.org/babel-walk/-/babel-walk-3.0.0-canary-5.tgz",
@@ -2882,6 +2891,11 @@
"nodetouch": "bin/nodetouch.js" "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": { "node_modules/type-is": {
"version": "1.6.18", "version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",

View File

@@ -13,6 +13,7 @@
"dependencies": { "dependencies": {
"@11ty/eleventy": "^2.0.1", "@11ty/eleventy": "^2.0.1",
"@11ty/eleventy-plugin-syntaxhighlight": "^5.0.2", "@11ty/eleventy-plugin-syntaxhighlight": "^5.0.2",
"async-mutex": "^0.5.0",
"cors": "^2.8.5", "cors": "^2.8.5",
"express": "^4.21.2", "express": "^4.21.2",
"express-rate-limit": "^8.0.1", "express-rate-limit": "^8.0.1",

View File

@@ -5,6 +5,7 @@ document.addEventListener('DOMContentLoaded', () => {
const slug = likeIcon.dataset.slug; const slug = likeIcon.dataset.slug;
const likeCountSpan = document.querySelector(`[data-like-count][data-slug="${slug}"]`); const likeCountSpan = document.querySelector(`[data-like-count][data-slug="${slug}"]`);
const storageKey = `liked-${slug}`; const storageKey = `liked-${slug}`;
let isRequestInProgress = false;
// Function to update the UI based on liked state and count // Function to update the UI based on liked state and count
const updateUI = (isLiked, count) => { const updateUI = (isLiked, count) => {
@@ -48,6 +49,12 @@ document.addEventListener('DOMContentLoaded', () => {
// Add click event listener to the icon // Add click event listener to the icon
likeIcon.addEventListener('click', async () => { 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 // Stop the hint animation if it's running
likeIcon.classList.remove('hint-animation'); likeIcon.classList.remove('hint-animation');
// Toggle the liked state // Toggle the liked state
@@ -56,7 +63,9 @@ document.addEventListener('DOMContentLoaded', () => {
try { try {
const response = await fetch(`/api/likes/${slug}`, { method }); 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 data = await response.json();
const isNowLiked = !isCurrentlyLiked; const isNowLiked = !isCurrentlyLiked;
@@ -67,6 +76,8 @@ document.addEventListener('DOMContentLoaded', () => {
} catch (error) { } catch (error) {
console.error('Error submitting like/unlike:', error); console.error('Error submitting like/unlike:', error);
} finally {
isRequestInProgress = false;
} }
}); });

View File

@@ -3,10 +3,15 @@ const cors = require('cors');
const fs = require('fs-extra'); const fs = require('fs-extra');
const path = require('path'); const path = require('path');
const rateLimit = require('express-rate-limit'); const rateLimit = require('express-rate-limit');
const { Mutex } = require('async-mutex');
const app = express(); const app = express();
const port = 3000; const port = 3000;
// Create mutexes to prevent race conditions when updating JSON files
const viewsMutex = new Mutex();
const likesMutex = new Mutex();
// trust proxy: // trust proxy:
// Used by express-rate-limit to obtain the client's IP address. // Used by express-rate-limit to obtain the client's IP address.
// '1' means that the first hop (your reverse proxy) is trusted. // '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 // POST: Increment the view count for a specific post slug
app.post('/api/views/:slug', validateSlug, async (req, res) => { app.post('/api/views/:slug', validateSlug, async (req, res) => {
const release = await viewsMutex.acquire();
try { try {
const { slug } = req.params; const { slug } = req.params;
const views = await fs.readJson(dbPath); const views = await fs.readJson(dbPath);
@@ -106,11 +112,14 @@ app.post('/api/views/:slug', validateSlug, async (req, res) => {
} catch (error) { } catch (error) {
console.error('Error updating view count:', error); console.error('Error updating view count:', error);
res.status(500).json({ error: 'Internal Server Error' }); res.status(500).json({ error: 'Internal Server Error' });
} finally {
release();
} }
}); });
// POST: Increment the like count for a specific post slug // POST: Increment the like count for a specific post slug
app.post('/api/likes/:slug', validateSlug, async (req, res) => { app.post('/api/likes/:slug', validateSlug, async (req, res) => {
const release = await likesMutex.acquire();
try { try {
const { slug } = req.params; const { slug } = req.params;
const likes = await fs.readJson(likesDbPath); const likes = await fs.readJson(likesDbPath);
@@ -120,11 +129,14 @@ app.post('/api/likes/:slug', validateSlug, async (req, res) => {
} catch (error) { } catch (error) {
console.error('Error updating like count:', error); console.error('Error updating like count:', error);
res.status(500).json({ error: 'Internal Server Error' }); res.status(500).json({ error: 'Internal Server Error' });
} finally {
release();
} }
}); });
// DELETE: Decrement the like count for a specific post slug (unlike) // DELETE: Decrement the like count for a specific post slug (unlike)
app.delete('/api/likes/:slug', validateSlug, async (req, res) => { app.delete('/api/likes/:slug', validateSlug, async (req, res) => {
const release = await likesMutex.acquire();
try { try {
const { slug } = req.params; const { slug } = req.params;
const likes = await fs.readJson(likesDbPath); const likes = await fs.readJson(likesDbPath);
@@ -139,6 +151,8 @@ app.delete('/api/likes/:slug', validateSlug, async (req, res) => {
} catch (error) { } catch (error) {
console.error('Error updating like count:', error); console.error('Error updating like count:', error);
res.status(500).json({ error: 'Internal Server Error' }); res.status(500).json({ error: 'Internal Server Error' });
} finally {
release();
} }
}); });