168 lines
5.3 KiB
JavaScript
168 lines
5.3 KiB
JavaScript
const express = require('express');
|
|
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.
|
|
app.set('trust proxy', 1);
|
|
|
|
// --- Security Middleware ---
|
|
|
|
// 1. CORS Configuration: Only allow requests from your own domain.
|
|
// We create a list of allowed origins, so the API works in both
|
|
// development (localhost) and production (the APP_URL).
|
|
const allowedOrigins = ['http://localhost:8080'];
|
|
if (process.env.APP_URL) {
|
|
allowedOrigins.push(process.env.APP_URL);
|
|
}
|
|
const corsOptions = {
|
|
origin: allowedOrigins,
|
|
optionsSuccessStatus: 200
|
|
};
|
|
app.use(cors(corsOptions));
|
|
|
|
// 2. Rate Limiting: Apply to all API requests to prevent abuse.
|
|
const apiLimiter = rateLimit({
|
|
windowMs: 15 * 60 * 1000, // 15 minutes
|
|
max: 100, // Limit each IP to 100 requests per windowMs
|
|
});
|
|
app.use('/api/', apiLimiter);
|
|
|
|
// Path to the file where metadata counts will be stored
|
|
const dbPath = path.join(__dirname, '_data', 'views.json');
|
|
const likesDbPath = path.join(__dirname, '_data', 'likes.json');
|
|
|
|
// Ensure the data directory and the .json files exist
|
|
fs.ensureFileSync(dbPath);
|
|
const initialData = fs.readFileSync(dbPath, 'utf8');
|
|
if (initialData.length === 0) {
|
|
fs.writeJsonSync(dbPath, {});
|
|
}
|
|
|
|
fs.ensureFileSync(likesDbPath);
|
|
const initialLikesData = fs.readFileSync(likesDbPath, 'utf8');
|
|
if (initialLikesData.length === 0) {
|
|
fs.writeJsonSync(likesDbPath, {});
|
|
}
|
|
|
|
app.use(express.json());
|
|
|
|
// In production, the server also serves the static site from the `_site` directory.
|
|
app.use(express.static('_site'));
|
|
|
|
// 3. Input Validation Middleware
|
|
const validateSlug = (req, res, next) => {
|
|
const { slug } = req.params;
|
|
// Basic slug validation: allow alphanumeric characters, hyphens, underscores, and dots.
|
|
if (!/^[a-z0-9_.-]+$/.test(slug)) {
|
|
return res.status(400).json({ error: 'Invalid slug format.' });
|
|
}
|
|
next();
|
|
};
|
|
|
|
// --- API Endpoints ---
|
|
|
|
// GET: Fetch the view count for a specific post slug
|
|
app.get('/api/views/:slug', validateSlug, async (req, res) => {
|
|
try {
|
|
const { slug } = req.params;
|
|
const views = await fs.readJson(dbPath);
|
|
const count = views[slug] || 0;
|
|
res.json({ count });
|
|
} catch (error) {
|
|
console.error('Error reading view count:', error);
|
|
res.status(500).json({ error: 'Internal Server Error' });
|
|
}
|
|
});
|
|
|
|
// GET: Fetch the like count for a specific post slug
|
|
app.get('/api/likes/:slug', validateSlug, async (req, res) => {
|
|
try {
|
|
const { slug } = req.params;
|
|
const likes = await fs.readJson(likesDbPath);
|
|
const count = likes[slug] || 0;
|
|
res.json({ count });
|
|
} catch (error) {
|
|
console.error('Error reading like count:', error);
|
|
res.status(500).json({ error: 'Internal Server Error' });
|
|
}
|
|
});
|
|
|
|
|
|
// 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);
|
|
views[slug] = (views[slug] || 0) + 1;
|
|
await fs.writeJson(dbPath, views);
|
|
res.json({ count: views[slug] });
|
|
} 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);
|
|
likes[slug] = (likes[slug] || 0) + 1;
|
|
await fs.writeJson(likesDbPath, likes);
|
|
res.json({ count: likes[slug] });
|
|
} 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);
|
|
// Ensure the count doesn't go below zero
|
|
if (likes[slug] > 0) {
|
|
likes[slug] -= 1;
|
|
} else {
|
|
likes[slug] = 0;
|
|
}
|
|
await fs.writeJson(likesDbPath, likes);
|
|
res.json({ count: likes[slug] });
|
|
} catch (error) {
|
|
console.error('Error updating like count:', error);
|
|
res.status(500).json({ error: 'Internal Server Error' });
|
|
} finally {
|
|
release();
|
|
}
|
|
});
|
|
|
|
// --- 404 Handler (Catch-All) ---
|
|
// This MUST be the last route or middleware added.
|
|
app.use((req, res, next) => {
|
|
res.redirect('/404.html');
|
|
});
|
|
|
|
app.listen(port, () => {
|
|
console.log(`Server listening at http://localhost:${port}`);
|
|
});
|