Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand All @@ -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",
Expand Down
36 changes: 36 additions & 0 deletions public/admin.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Admin — Audit & Recompute</title>
<link rel="stylesheet" href="/style.css">
<style>
body { padding: 1rem; font-family: Arial, sans-serif; }
.card { background:#fff; border-radius:8px; padding:1rem; box-shadow:0 4px 18px rgba(0,0,0,0.06); max-width:900px; margin:0 auto }
textarea { width:100%; height:240px; font-family: monospace; white-space:pre; }
.row { display:flex; gap:8px; margin-bottom:8px }
input[type=text] { flex:1 }
</style>
</head>
<body>
<div class="card">
<h2>Admin: Audit & Recompute</h2>
<p>Paste an admin JWT below, then run the audit or recompute operations.</p>
<div class="row">
<input id="token" type="text" placeholder="Bearer token" />
<button id="btn-audit">Audit Indexes</button>
<button id="btn-recompute">Recompute Like Counts</button>
</div>

<div style="margin-bottom:8px">
<button id="btn-history">Load Audit History</button>
</div>

<h3>Result</h3>
<textarea id="output" readonly></textarea>
</div>

<script src="/admin.js"></script>
</body>
</html>
31 changes: 31 additions & 0 deletions public/admin.js
Original file line number Diff line number Diff line change
@@ -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);
});
131 changes: 125 additions & 6 deletions server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
}),
Comment on lines +549 to +553
extraRoutes: (app, basePath, collection, label, col) => {
addLikeRoute(app, basePath, collection, label, col);
}
Expand Down Expand Up @@ -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 ====================

/**
Expand Down
77 changes: 77 additions & 0 deletions tools/adminAudit.js
Original file line number Diff line number Diff line change
@@ -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();
58 changes: 58 additions & 0 deletions tools/adminForceRecompute.js
Original file line number Diff line number Diff line change
@@ -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", []] } }
Comment on lines +24 to +25
}
}
];

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();