mongo-express
Version:
Web-based admin interface for MongoDB
372 lines (311 loc) • 13.4 kB
JavaScript
import { fileURLToPath, URL } from 'node:url';
import basicAuth from 'basic-auth-connect';
import bodyParser from 'body-parser';
import cookieParser from 'cookie-parser';
import errorHandler from 'errorhandler';
import express from 'express';
import favicon from 'serve-favicon';
import logger from 'morgan';
import methodOverride from 'method-override';
import mongodb from 'mongodb';
import pico from 'picocolors';
import session from 'express-session';
import memorystore from 'memorystore';
import csrf from 'csurf';
import db from './db.js';
import routes from './routes/index.js';
import { buildCollectionURL, buildDatabaseURL, colsToGrid } from './utils.js';
const MemoryStore = memorystore(session);
const ALLOWED_SUBTYPES = new Set([
mongodb.Binary.SUBTYPE_UUID_OLD,
mongodb.Binary.SUBTYPE_UUID,
]);
const addTrailingSlash = function (s) {
return s + (s.at(-1) === '/' ? '' : '/');
};
const buildBaseHref = function (originalUrl, reqUrl) {
if (reqUrl === '/') {
return addTrailingSlash(originalUrl);
}
const idx = originalUrl.lastIndexOf(reqUrl);
const rootPath = originalUrl.slice(0, idx);
return addTrailingSlash(rootPath);
};
const buildId = function (id, query) {
// Case 1 : ObjectId
try {
return mongodb.BSON.ObjectId.createFromHexString(id);
} catch {
// Case 2 : BinaryID (only subtype 3 and 4)
if (('subtype' in query)) {
const subtype = Number.parseInt(query.subtype, 10);
if (ALLOWED_SUBTYPES.has(subtype)) {
if (subtype === mongodb.Binary.SUBTYPE_UUID) {
return new mongodb.Binary(new Buffer.from(id.replaceAll('-', ''), 'hex'), query.subtype);
}
// mongodb.Binary.SUBTYPE_UUID_OLD
return new mongodb.Binary(new Buffer.from(id, 'base64'), query.subtype);
}
// ignore wrong subtype
}
// Case 3 : Try as raw ID
return id;
}
};
const router = async function (config) {
// appRouter configuration
const appRouter = express.Router();
let mongo = null;
try {
mongo = await db(config);
} catch (error) {
console.debug(error);
}
appRouter.get(config.healthCheck.path, (req, res) => {
res.json({ status: 'ok' });
});
if (config.useBasicAuth) {
appRouter.use(basicAuth(config.basicAuth.username, config.basicAuth.password));
}
if (config.useOidcAuth) {
let oidcAuth;
try {
// eslint-disable-next-line import/no-extraneous-dependencies
({ auth: oidcAuth } = await import('express-openid-connect'));
} catch (error) {
console.error(pico.red('OIDC authentication is enabled but the "express-openid-connect" package isn\'t installed.'));
console.error(pico.red('Please install the "express-openid-connect" package or disable OIDC authentication in the configuration file.'));
throw error;
}
appRouter.use(oidcAuth(config.oidcAuth));
}
appRouter.use(favicon(fileURLToPath(new URL('../public/images/favicon.ico', import.meta.url))));
appRouter.use(logger('dev', config.options.logger));
appRouter.use('/public', express.static(fileURLToPath(new URL('../build', import.meta.url))));
appRouter.use(bodyParser.json());
// Set request size limit
appRouter.use(bodyParser.urlencoded({
extended: true,
limit: config.site.requestSizeLimit,
}));
appRouter.use(cookieParser(config.site.cookieSecret));
appRouter.use(session({
key: config.site.cookieKeyName,
resave: true,
saveUninitialized: true,
secret: config.site.sessionSecret,
store: new MemoryStore({
checkPeriod: 86400000, // prune expired entries every 24h
}),
}));
appRouter.use(process.env.NODE_ENV === 'test' ? csrf({ ignoreMethods: ['GET', 'HEAD', 'OPTIONS', 'POST', 'PUT'] })
: csrf({ cookie: true }));
appRouter.use(methodOverride(function (req) {
if (req.body && typeof req.body === 'object' && '_method' in req.body) {
// look in urlencoded POST bodies and delete it
const method = req.body._method;
delete req.body._method;
return method;
}
}));
if (process.env.NODE_ENV === 'development') {
appRouter.use(errorHandler());
}
// View helper, sets local variables used in templates
appRouter.all('*', async function (req, res, next) {
if (mongo === null) {
const connection = new URL(config.mongodb.connectionString);
if (req.method === 'POST') {
const {
authSource, hostname, port,
username, password,
} = req.body;
connection.hostname = hostname;
connection.port = port;
connection.username = username;
connection.password = password;
connection.searchParams.set('authSource', authSource);
config.mongodb.connectionString = connection.toString();
try {
mongo = await db(config);
return res.redirect(config.site.baseUrl);
} catch (error) {
console.debug(error);
}
}
return res.render('login', {
csrfToken: req.csrfToken(),
authSource: connection.searchParams.get('authSource') ?? '',
username: connection.username,
password: connection.password,
hostname: connection.hostname,
port: connection.port,
});
}
res.locals.baseHref = buildBaseHref(req.originalUrl, req.url);
res.locals.databases = mongo.getDatabases();
res.locals.collections = mongo.collections;
res.locals.gridFSBuckets = colsToGrid(mongo.collections);
res.locals.enableLogout = config.useOidcAuth;
// Flash messages
if (req.session.success) {
res.locals.messageSuccess = req.session.success;
delete req.session.success;
}
if (req.session.error) {
res.locals.messageError = req.session.error;
delete req.session.error;
}
await mongo.updateDatabases().then(() => {
res.locals.databases = mongo.getDatabases();
next();
}).catch(next);
});
// route param pre-conditions
appRouter.param('database', function (req, res, next, id) {
// Make sure database exists
if (!mongo.connections[id]) {
req.session.error = 'Database not found!';
return res.redirect(res.locals.baseHref);
}
req.dbName = id;
res.locals.dbName = id;
res.locals.dbUrl = buildDatabaseURL(res.locals.baseHref, id);
req.dbConnection = mongo.connections[id];
req.db = mongo.connections[id].db;
next();
});
// :collection param MUST be preceded by a :database param
appRouter.param('collection', function (req, res, next, id) {
// Make sure collection exists
if (!mongo.collections[req.dbName].includes(id)) {
req.session.error = 'Collection not found!';
return res.redirect(res.locals.baseHref + 'db/' + req.dbName);
}
req.collectionName = id;
res.locals.collectionName = id;
res.locals.collectionUrl = buildCollectionURL(res.locals.baseHref, res.locals.dbName, id);
res.locals.collections = mongo.collections[req.dbName];
res.locals.gridFSBuckets = colsToGrid(mongo.collections[req.dbName]);
const coll = mongo.connections[req.dbName].db.collection(id);
if (coll === null) {
req.session.error = 'Collection not found!';
return res.redirect(res.locals.baseHref + 'db/' + req.dbName);
}
req.collection = coll;
next();
});
// :document param MUST be preceded by a :collection param
appRouter.param('document', async function (req, res, next, id) {
if (id === 'undefined' || id === undefined) {
req.session.error = 'Document lacks an _id!';
return res.redirect(res.locals.baseHref + 'db/' + req.dbName + '/' + req.collectionName);
}
id = JSON.parse(decodeURIComponent(id));
const _id = buildId(id, req.query);
// If an ObjectId was correctly created from passed id param, try getting the ObjID first else falling back to try getting the string id
// If not valid ObjectId created, try getting string id
try {
let doc = await req.collection.findOne({ _id });
if (doc === null) {
// No document found with obj_id, try again with straight id
doc = await req.collection.findOne({ _id: id });
}
if (doc === null) {
req.session.error = 'Document not found!';
} else {
// Document found - send it back
req.document = doc;
res.locals.document = doc;
return next();
}
} catch (error) {
req.session.error = 'Error: ' + error;
console.error(error);
}
res.redirect(res.locals.baseHref + 'db/' + req.dbName + '/' + req.collectionName);
});
// get individual property - for async loading of big documents
// (db)/(collection)/(document)/(prop)
appRouter.param('prop', function (req, res, next, prop) {
req.prop = req.document[prop];
next();
});
// GridFS (db)/gridFS/(bucket)
appRouter.param('bucket', async function (req, res, next, id) {
req.bucketName = id;
res.locals.bucketName = id;
await mongo.connections[req.dbName].collection(id + '.files').then(async (filesConn) => {
if (filesConn === null) {
req.session.error = id + '.files collection not found!';
return res.redirect(res.locals.baseHref + 'db/' + req.dbName);
}
req.filesConn = filesConn;
await filesConn.find().toArray().then((files) => {
if (files === null) {
req.session.error = id + '.files collection not found!';
return res.redirect(res.locals.baseHref + 'db/' + req.dbName);
}
req.files = files;
next();
});
}).catch((error) => {
req.session.error = id + '.files collection not found! Err:' + error;
res.redirect(res.locals.baseHref + 'db/' + req.dbName);
});
});
// GridFS files
appRouter.param('file', function (req, res, next, id) {
req.fileID = JSON.parse(decodeURIComponent(id));
next();
});
// mongodb mongoMiddleware
const mongoMiddleware = function (req, res, next) {
req.mainClient = mongo.mainClient;
req.adminDb = mongo.mainClient.adminDb || undefined;
req.databases = mongo.getDatabases(); // List of database names
req.collections = mongo.collections; // List of collection names in all databases
req.gridFSBuckets = colsToGrid(mongo.collections);
// Allow page handlers to request an update for collection list
req.updateCollections = mongo.updateCollections;
req.updateDatabases = mongo.updateDatabases;
next();
};
// routes
const configuredRoutes = routes(config);
appRouter.get('/', mongoMiddleware, configuredRoutes.index);
appRouter.post('/', mongoMiddleware, configuredRoutes.addDatabase);
appRouter.delete('/:database', mongoMiddleware, configuredRoutes.deleteDatabase);
appRouter.get('/db/:database', mongoMiddleware, configuredRoutes.viewDatabase);
appRouter.post('/checkValid', mongoMiddleware, configuredRoutes.checkValid);
// Collection level routes
appRouter.post('/db/:database/import/:collection', mongoMiddleware, configuredRoutes.importCollection);
appRouter.get('/db/:database/compact/:collection', mongoMiddleware, configuredRoutes.compactCollection);
appRouter.get('/db/:database/expArr/:collection', mongoMiddleware, configuredRoutes.exportColArray);
appRouter.get('/db/:database/expCsv/:collection', mongoMiddleware, configuredRoutes.exportCsv);
appRouter.get('/db/:database/reIndex/:collection', mongoMiddleware, configuredRoutes.reIndex);
appRouter.post('/db/:database/addIndex/:collection', mongoMiddleware, configuredRoutes.addIndex);
appRouter.get('/db/:database/export/:collection', mongoMiddleware, configuredRoutes.exportCollection);
appRouter.get('/db/:database/dropIndex/:collection', mongoMiddleware, configuredRoutes.dropIndex);
appRouter.get('/db/:database/updateCollections', mongoMiddleware, configuredRoutes.updateCollections);
// GridFS
appRouter.post('/db/:database/gridFS', mongoMiddleware, configuredRoutes.addBucket);
appRouter.delete('/db/:database/gridFS/:bucket', mongoMiddleware, configuredRoutes.deleteBucket);
appRouter.get('/db/:database/gridFS/:bucket', mongoMiddleware, configuredRoutes.viewBucket);
appRouter.post('/db/:database/gridFS/:bucket', mongoMiddleware, configuredRoutes.addFile);
appRouter.get('/db/:database/gridFS/:bucket/:file', mongoMiddleware, configuredRoutes.getFile);
appRouter.delete('/db/:database/gridFS/:bucket/:file', mongoMiddleware, configuredRoutes.deleteFile);
appRouter.get('/db/:database/:collection', mongoMiddleware, configuredRoutes.viewCollection);
appRouter.put('/db/:database/:collection', mongoMiddleware, configuredRoutes.renameCollection);
appRouter.delete('/db/:database/:collection', mongoMiddleware, configuredRoutes.deleteCollection);
appRouter.post('/db/:database', mongoMiddleware, configuredRoutes.addCollection);
// Document routes
appRouter.post('/db/:database/:collection', mongoMiddleware, configuredRoutes.addDocument);
appRouter.get('/db/:database/:collection/:document', mongoMiddleware, configuredRoutes.viewDocument);
appRouter.put('/db/:database/:collection/:document', mongoMiddleware, configuredRoutes.updateDocument);
appRouter.delete('/db/:database/:collection/:document', mongoMiddleware, configuredRoutes.deleteDocument);
// Property routes
appRouter.get('/db/:database/:collection/:document/:prop', mongoMiddleware, configuredRoutes.getProperty);
return appRouter;
};
export default router;