diff --git a/package.json b/package.json index 45dc2a0..cd71005 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "start": "node server.js", "dev": "nodemon server.js", "test": "jest --forceExit --detectOpenHandles", + "admin-audit": "node tools/adminAudit.js", "docker:up": "docker compose up --build", "docker:down": "docker compose down" }, @@ -22,7 +23,7 @@ "express": "^5.2.1", "express-rate-limit": "^8.5.2", "helmet": "^8.2.0", - "jsdom": "^29.1.1", + "jsdom": "^22.1.0", "jsonwebtoken": "^9.0.3", "mongodb": "^6.21.0", "swagger-jsdoc": "^6.3.0", diff --git a/public/admin.html b/public/admin.html new file mode 100644 index 0000000..f1fbb89 --- /dev/null +++ b/public/admin.html @@ -0,0 +1,36 @@ + + + + + + Admin — Audit & Recompute + + + + +
+

Admin: Audit & Recompute

+

Paste an admin JWT below, then run the audit or recompute operations.

+
+ + + +
+ +
+ +
+ +

Result

+ +
+ + + + diff --git a/public/admin.js b/public/admin.js new file mode 100644 index 0000000..d11a130 --- /dev/null +++ b/public/admin.js @@ -0,0 +1,31 @@ +async function callApi(path, method='GET', token=null) { + const headers = { 'Content-Type': 'application/json' }; + if (token) headers['Authorization'] = token.startsWith('Bearer ') ? token : `Bearer ${token}`; + const res = await fetch(path, { method, headers }); + const text = await res.text(); + try { return JSON.parse(text); } catch (e) { return { status: res.status, body: text }; } +} + +const out = document.getElementById('output'); +const tokenInput = document.getElementById('token'); + +document.getElementById('btn-audit').addEventListener('click', async () => { + out.value = 'Running audit...'; + const t = tokenInput.value.trim(); + const result = await callApi('/api/admin/audit-indexes', 'GET', t || null); + out.value = JSON.stringify(result, null, 2); +}); + +document.getElementById('btn-recompute').addEventListener('click', async () => { + out.value = 'Running recompute...'; + const t = tokenInput.value.trim(); + const result = await callApi('/api/admin/recompute-like-counts', 'POST', t || null); + out.value = JSON.stringify(result, null, 2); +}); + +document.getElementById('btn-history').addEventListener('click', async () => { + out.value = 'Loading history...'; + const t = tokenInput.value.trim(); + const result = await callApi('/api/admin/audits', 'GET', t || null); + out.value = JSON.stringify(result, null, 2); +}); diff --git a/server.js b/server.js index 49089b3..a8ad0c3 100644 --- a/server.js +++ b/server.js @@ -543,14 +543,14 @@ createCrudRoutes({ title: body.title, imageUrl: body.imageUrl, category: body.category, - likeCount: body.likeCount || 0, + likeCount: 0, likedBy: [] }), - buildUpdate: (body) => { - const data = { title: body.title, imageUrl: body.imageUrl, category: body.category }; - if (body.likeCount !== undefined) data.likeCount = body.likeCount; - return data; - }, + buildUpdate: (body) => ({ + title: body.title, + imageUrl: body.imageUrl, + category: body.category + }), extraRoutes: (app, basePath, collection, label, col) => { addLikeRoute(app, basePath, collection, label, col); } @@ -680,6 +680,125 @@ app.delete('/posts/:postId/comments/:id', verifyToken, async (req, res) => { } }); +// -------------------- Admin Utilities: Recompute & Audit -------------------- + +async function recomputeLikeCountsForCollections(collectionNames) { + const summary = {}; + for (const name of collectionNames) { + const col = db.collection(name); + const cursor = col.find(); + let matched = 0, modified = 0, anomalies = 0; + const bulk = col.initializeUnorderedBulkOp(); + + while (await cursor.hasNext()) { + const doc = await cursor.next(); + const likedBy = Array.isArray(doc.likedBy) ? doc.likedBy : []; + const desired = likedBy.length; + const current = (typeof doc.likeCount === 'number' && Number.isFinite(doc.likeCount)) ? doc.likeCount : null; + + // Consider as anomaly if likeCount differs from desired, is missing, negative, non-integer, or extremely large + const isAnomaly = current !== desired || current === null || current < 0 || !Number.isInteger(current) || Math.abs(current) > 1e12; + if (isAnomaly) { + anomalies++; + matched++; + bulk.find({ _id: doc._id }).updateOne({ $set: { likedBy: likedBy, likeCount: desired } }); + } + } + + if (matched > 0) { + const result = await bulk.execute(); + modified = result.nModified || result.nModified === undefined ? (result.nModified || Object.values(result).reduce((s, v) => s + (v.nModified || 0), 0)) : result.nModified; + } + + summary[name] = { checked: true, matched, modified, anomalies }; + } + return summary; +} + +async function auditIndexes() { + const info = {}; + const collections = await db.listCollections().toArray(); + for (const c of collections) { + try { + const idx = await db.collection(c.name).indexes(); + info[c.name] = idx; + } catch (err) { + info[c.name] = { error: err.message }; + } + } + return info; +} + +/** + * @swagger + * /api/admin/recompute-like-counts: + * post: + * summary: Recompute like counts across collections (Admin only) + * tags: [Admin] + * security: [{ bearerAuth: [] }] + */ +app.post('/api/admin/recompute-like-counts', verifyToken, async (req, res) => { + try { + if (!isAdmin(req)) return sendError(res, HTTP_STATUS.FORBIDDEN, 'Only admins can perform this action'); + + // Collections to target — only those that may carry likeCount/likedBy fields + const targets = ['posts', 'designItems', 'staticBlogItems', 'achievers']; + const result = await recomputeLikeCountsForCollections(targets); + console.log('🛠️ Admin recompute performed by', req.user.username); + + // Persist audit record for history and UI + try { + const auditsCol = db.collection('adminAudits'); + await auditsCol.insertOne({ + action: 'recompute-like-counts', + user: req.user.username, + timestamp: new Date(), + result + }); + } catch (err) { + console.error('❌ Failed to persist admin audit record:', err.message); + } + + sendOk(res, HTTP_STATUS.OK, { result }); + } catch (err) { + console.error('❌ Admin recompute error:', err); + sendError(res, HTTP_STATUS.INTERNAL_SERVER_ERROR, 'Server error occurred'); + } +}); + +/** + * @swagger + * /api/admin/audit-indexes: + * get: + * summary: Audit DB indexes across collections (Admin only) + * tags: [Admin] + * security: [{ bearerAuth: [] }] + */ +app.get('/api/admin/audit-indexes', verifyToken, async (req, res) => { + try { + if (!isAdmin(req)) return sendError(res, HTTP_STATUS.FORBIDDEN, 'Only admins can perform this action'); + const idx = await auditIndexes(); + sendOk(res, HTTP_STATUS.OK, { indexes: idx }); + } catch (err) { + console.error('❌ Admin audit indexes error:', err); + sendError(res, HTTP_STATUS.INTERNAL_SERVER_ERROR, 'Server error occurred'); + } +}); + +// Return recent admin audit records +app.get('/api/admin/audits', verifyToken, async (req, res) => { + try { + if (!isAdmin(req)) return sendError(res, HTTP_STATUS.FORBIDDEN, 'Only admins can perform this action'); + const col = db.collection('adminAudits'); + const rows = await col.find().sort({ timestamp: -1 }).limit(100).toArray(); + sendOk(res, HTTP_STATUS.OK, { audits: rows }); + } catch (err) { + console.error('❌ Error fetching admin audits:', err); + sendError(res, HTTP_STATUS.INTERNAL_SERVER_ERROR, 'Server error occurred'); + } +}); + + // ==================== Authentication ==================== /** diff --git a/tools/adminAudit.js b/tools/adminAudit.js new file mode 100644 index 0000000..e6bb099 --- /dev/null +++ b/tools/adminAudit.js @@ -0,0 +1,77 @@ +require('dotenv').config(); + +const { MongoClient } = require('mongodb'); + +const uri = process.env.MONGO_URI || 'mongodb://localhost:27017/blogDB'; +const DB_NAME = process.env.DB_NAME || 'blogDB'; + +async function recomputeLikeCountsForCollections(db, collectionNames) { + const summary = {}; + for (const name of collectionNames) { + const col = db.collection(name); + const cursor = col.find(); + let matched = 0, modified = 0, anomalies = 0; + const bulk = col.initializeUnorderedBulkOp(); + + while (await cursor.hasNext()) { + const doc = await cursor.next(); + const likedBy = Array.isArray(doc.likedBy) ? doc.likedBy : []; + const desired = likedBy.length; + const current = (typeof doc.likeCount === 'number' && Number.isFinite(doc.likeCount)) ? doc.likeCount : null; + + const isAnomaly = current !== desired || current === null || current < 0 || !Number.isInteger(current) || Math.abs(current) > 1e12; + if (isAnomaly) { + anomalies++; + matched++; + bulk.find({ _id: doc._id }).updateOne({ $set: { likedBy: likedBy, likeCount: desired } }); + } + } + + if (matched > 0) { + const result = await bulk.execute(); + modified = result.nModified || result.nModified === undefined ? (result.nModified || Object.values(result).reduce((s, v) => s + (v.nModified || 0), 0)) : result.nModified; + } + + summary[name] = { checked: true, matched, modified, anomalies }; + } + return summary; +} + +async function auditIndexes(db) { + const info = {}; + const collections = await db.listCollections().toArray(); + for (const c of collections) { + try { + const idx = await db.collection(c.name).indexes(); + info[c.name] = idx; + } catch (err) { + info[c.name] = { error: err.message }; + } + } + return info; +} + +async function main() { + const client = new MongoClient(uri); + try { + await client.connect(); + const db = client.db(DB_NAME); + console.log('Connected to', uri, 'db:', DB_NAME); + + const targets = ['posts', 'designItems', 'staticBlogItems', 'achievers']; + console.log('Recomputing like counts for:', targets.join(', ')); + const recompute = await recomputeLikeCountsForCollections(db, targets); + console.log('Recompute result:', JSON.stringify(recompute, null, 2)); + + console.log('\nAuditing indexes...'); + const idx = await auditIndexes(db); + console.log('Indexes summary:', JSON.stringify(idx, null, 2)); + + } catch (err) { + console.error('Error running admin audit:', err); + } finally { + await client.close(); + } +} + +if (require.main === module) main(); diff --git a/tools/adminForceRecompute.js b/tools/adminForceRecompute.js new file mode 100644 index 0000000..4e4b8fd --- /dev/null +++ b/tools/adminForceRecompute.js @@ -0,0 +1,58 @@ +require('dotenv').config(); +const { MongoClient } = require('mongodb'); + +const uri = process.env.MONGO_URI || 'mongodb://localhost:27017/blogDB'; +const DB_NAME = process.env.DB_NAME || 'blogDB'; + +async function forceRecompute(db, name) { + const col = db.collection(name); + + // Build filter: documents where likeCount != size(likedBy) OR likeCount missing/invalid + const filter = { + $expr: { + $ne: [ + { $ifNull: ["$likeCount", null] }, + { $size: { $ifNull: ["$likedBy", []] } } + ] + } + }; + + // Update using aggregation pipeline to set likeCount to size(likedBy) + const update = [ + { + $set: { + likedBy: { $ifNull: ["$likedBy", []] }, + likeCount: { $size: { $ifNull: ["$likedBy", []] } } + } + } + ]; + + const result = await col.updateMany(filter, update); + return { matched: result.matchedCount, modified: result.modifiedCount }; +} + +async function main() { + const client = new MongoClient(uri); + try { + await client.connect(); + const db = client.db(DB_NAME); + console.log('Connected to', uri, 'db:', DB_NAME); + + const targets = ['posts', 'designItems', 'staticBlogItems', 'achievers']; + const summary = {}; + for (const t of targets) { + console.log('Processing', t); + const res = await forceRecompute(db, t); + summary[t] = res; + console.log(` -> matched=${res.matched}, modified=${res.modified}`); + } + + console.log('\nForce recompute summary:', JSON.stringify(summary, null, 2)); + } catch (err) { + console.error('Error:', err); + } finally { + await client.close(); + } +} + +if (require.main === module) main();