diff --git a/src/apps.js b/src/apps.js index 70ddc3616..6f95e722f 100644 --- a/src/apps.js +++ b/src/apps.js @@ -1466,14 +1466,19 @@ async function listBackups(app, page, perPage) { return await backups.listByIdentifierAndStatePaged(app.id, backups.BACKUP_STATE_NORMAL, page, perPage); } -async function listEventlog(app, page, perPage) { +async function listEventlog(app, filter, page, perPage) { assert.strictEqual(typeof app, 'object'); + assert.strictEqual(typeof filter, 'object'); assert.strictEqual(typeof page, 'number'); assert.strictEqual(typeof perPage, 'number'); - const actions = []; - const search = app.id; - return await eventlog.listPaged(actions, search, page, perPage); + const fullFilter = { + actions: [], + search: app.id, + from: filter.from, + to: filter.to + }; + return await eventlog.listPaged(fullFilter, page, perPage); } async function drainStream(stream) { diff --git a/src/domains.js b/src/domains.js index f8eed085e..0ac9ec66a 100644 --- a/src/domains.js +++ b/src/domains.js @@ -290,7 +290,6 @@ async function del(domain, auditSource) { ]; const [error, results] = await safe(database.transaction(queries)); -console.dir(error); if (error && error.sqlCode === 'ER_ROW_IS_REFERENCED_2') { if (error.message.includes('mailboxes_aliasDomain_constraint')) throw new BoxError(BoxError.CONFLICT, 'Domain is in use in a mailbox, list or an alias'); if (error.message.includes('mailboxes_domain_constraint')) throw new BoxError(BoxError.CONFLICT, 'Domain is in use in a mailbox, list or an alias'); diff --git a/src/eventlog.js b/src/eventlog.js index 3982c00b2..3aa68bb33 100644 --- a/src/eventlog.js +++ b/src/eventlog.js @@ -76,28 +76,38 @@ async function getActivationEvent() { return postProcess(result[0]); } -async function listPaged(actions, search, page, perPage) { +async function listPaged(filter, page, perPage) { + const { actions, search, from = null, to = null } = filter; + assert(Array.isArray(actions)); assert(typeof search === 'string' || search === null); + assert(from === null || from instanceof Date, 'from must be a Date or null'); + assert(to === null || to instanceof Date, 'to must be a Date or null'); assert.strictEqual(typeof page, 'number'); assert.strictEqual(typeof perPage, 'number'); - const data = []; + const conditions = [], data = []; + + if (search) conditions.push('(sourceJson LIKE ' + mysql.escape('%' + search + '%') + ' OR dataJson LIKE ' + mysql.escape('%' + search + '%') + ')'); + + if (actions.length) { + const actionConds = actions.map((a) => ' (action LIKE ' + mysql.escape(`%${a}%`) + ') ').join(' OR '); + conditions.push('( ' + actionConds + ' )'); + } + if (from) { + conditions.push('creationTime >= ?'); + data.push(from); + } + if (to) { + conditions.push('creationTime <= ?'); + data.push(to); + } + let query = `SELECT ${EVENTLOG_FIELDS} FROM eventlog`; - - if (actions.length || search) query += ' WHERE'; - if (search) query += ' (sourceJson LIKE ' + mysql.escape('%' + search + '%') + ' OR dataJson LIKE ' + mysql.escape('%' + search + '%') + ')'; - - if (actions.length && search) query += ' AND ( '; - actions.forEach(function (action, i) { - query += ' (action LIKE ' + mysql.escape(`%${action}%`) + ') '; - if (i < actions.length-1) query += ' OR '; - }); - if (actions.length && search) query += ' ) '; - + if (conditions.length) query += ' WHERE ' + conditions.join(' AND '); query += ' ORDER BY creationTime DESC LIMIT ?,?'; - data.push((page-1)*perPage); + data.push((page - 1) * perPage); data.push(perPage); const results = await database.query(query, data); diff --git a/src/routes/apps.js b/src/routes/apps.js index 8fa73dade..2ae78abe6 100644 --- a/src/routes/apps.js +++ b/src/routes/apps.js @@ -1,4 +1,5 @@ import apps from '../apps.js'; +import validator from '../validator.js'; import appstore from '../appstore.js'; import assert from 'node:assert'; import AuditSource from '../auditsource.js'; @@ -993,7 +994,21 @@ async function listEventlog(req, res, next) { const perPage = typeof req.query.per_page === 'string'? parseInt(req.query.per_page) : 25; if (!perPage || perPage < 0) return next(new HttpError(400, 'per_page query param has to be a postive number')); - const [error, eventlogs] = await safe(apps.listEventlog(req.resources.app, page, perPage)); + if (req.query.from) { + if (typeof req.query.from !== 'string') return next(new HttpError(400, 'from must be an ISO 8601 datetime string')); + if (!validator.isIsoDate(req.query.from)) return next(new HttpError(400, 'from must be a valid ISO 8601 datetime string')); + } + + if (req.query.to) { + if (typeof req.query.to !== 'string') return next(new HttpError(400, 'to must be an ISO 8601 datetime string')); + if (!validator.isIsoDate(req.query.to)) return next(new HttpError(400, 'to must be a valid ISO 8601 datetime string')); + } + + const filter = { + from: req.query.from ? new Date(req.query.from) : null, + to: req.query.to ? new Date(req.query.to) : null + }; + const [error, eventlogs] = await safe(apps.listEventlog(req.resources.app, filter, page, perPage)); if (error) return next(BoxError.toHttpError(error)); next(new HttpSuccess(200, { eventlogs })); diff --git a/src/routes/eventlog.js b/src/routes/eventlog.js index e326c61a5..1d411f9fc 100644 --- a/src/routes/eventlog.js +++ b/src/routes/eventlog.js @@ -1,5 +1,6 @@ import BoxError from '../boxerror.js'; import eventlog from '../eventlog.js'; +import validator from '../validator.js'; import { HttpError } from '@cloudron/connect-lastmile'; import { HttpSuccess } from '@cloudron/connect-lastmile'; import safe from 'safetydance'; @@ -24,10 +25,29 @@ async function list(req, res, next) { if (req.query.action && typeof req.query.action !== 'string') return next(new HttpError(400, 'action must be a string')); if (req.query.search && typeof req.query.search !== 'string') return next(new HttpError(400, 'search must be a string')); + if (req.query.from) { + if (typeof req.query.from !== 'string') return next(new HttpError(400, 'from must be an ISO 8601 datetime string')); + if (!validator.isIsoDate(req.query.from)) return next(new HttpError(400, 'from must be a valid ISO 8601 datetime string')); + } + + if (req.query.to) { + if (typeof req.query.to !== 'string') return next(new HttpError(400, 'to must be an ISO 8601 datetime string')); + if (!validator.isIsoDate(req.query.to)) return next(new HttpError(400, 'to must be a valid ISO 8601 datetime string')); + } + const actions = typeof req.query.actions === 'string' ? req.query.actions.split(',').map(function (s) { return s.trim(); }) : []; if (req.query.action) actions.push(req.query.action); - const [error, eventlogs] = await safe(eventlog.listPaged(actions, req.query.search || null, page, perPage)); + if (req.query.to && !validator.isIsoDate(req.query.to)) return next(new HttpError(400, 'to must be a valid ISO 8601 datetime string')); + + const filter = { + actions, + search: req.query.search || null, + from: req.query.from ? new Date(req.query.from) : null, + to: req.query.to ? new Date(req.query.to) : null + }; + + const [error, eventlogs] = await safe(eventlog.listPaged(filter, page, perPage)); if (error) return next(BoxError.toHttpError(error)); next(new HttpSuccess(200, { eventlogs })); diff --git a/src/routes/test/eventlog-test.js b/src/routes/test/eventlog-test.js index 07c05edf3..7ac954063 100644 --- a/src/routes/test/eventlog-test.js +++ b/src/routes/test/eventlog-test.js @@ -118,5 +118,31 @@ describe('Eventlog API', function () { expect(response.status).to.equal(200); expect(response.body.eventlogs.length).to.equal(0); }); + + it('succeeds with from and to', async function () { + const from = new Date(Date.now() - 86400000).toISOString(); // 1 day ago + const to = new Date(Date.now() + 86400000).toISOString(); // 1 day from now + const response = await superagent.get(`${serverUrl}/api/v1/eventlog`) + .query({ access_token: owner.token, page: 1, per_page: 10, from, to }); + + expect(response.status).to.equal(200); + expect(response.body.eventlogs).to.be.an('array'); + }); + + it('fails with invalid from', async function () { + const response = await superagent.get(`${serverUrl}/api/v1/eventlog`) + .query({ access_token: owner.token, page: 1, per_page: 10, from: 'not-a-date' }) + .ok(() => true); + + expect(response.status).to.equal(400); + }); + + it('fails with invalid to', async function () { + const response = await superagent.get(`${serverUrl}/api/v1/eventlog`) + .query({ access_token: owner.token, page: 1, per_page: 10, to: 'invalid' }) + .ok(() => true); + + expect(response.status).to.equal(400); + }); }); }); diff --git a/src/test/eventlog-test.js b/src/test/eventlog-test.js index 3486bd788..07fad3239 100644 --- a/src/test/eventlog-test.js +++ b/src/test/eventlog-test.js @@ -47,7 +47,7 @@ describe('Eventlog', function () { }); it('listPaged succeeds', async function () { - const results = await eventlog.listPaged([], null, 1, 1); + const results = await eventlog.listPaged({ actions: [], search: null }, 1, 1); expect(results).to.be.an(Array); expect(results.length).to.be(1); @@ -58,7 +58,7 @@ describe('Eventlog', function () { }); it('listPaged succeeds with source search', async function () { - const results = await eventlog.listPaged([], '1.2.3.4', 1, 1); + const results = await eventlog.listPaged({ actions: [], search: '1.2.3.4' }, 1, 1); expect(results).to.be.an(Array); expect(results.length).to.be(1); expect(results[0].id).to.be(eventId); @@ -68,7 +68,7 @@ describe('Eventlog', function () { }); it('listPaged succeeds with data search', async function () { - const results = await eventlog.listPaged([], 'thatapp', 1, 1); + const results = await eventlog.listPaged({ actions: [], search: 'thatapp' }, 1, 1); expect(results).to.be.an(Array); expect(results.length).to.be(1); expect(results[0].id).to.be(eventId); @@ -77,6 +77,26 @@ describe('Eventlog', function () { expect(results[0].data).to.be.eql({ appId: 'thatapp' }); }); + it('listPaged succeeds with from/to time filter', async function () { + const event = await eventlog.get(eventId); + const creationTime = event.creationTime; + const from = new Date(creationTime.getTime() - 60000); // 1 min before + const to = new Date(creationTime.getTime() + 60000); // 1 min after + + const results = await eventlog.listPaged({ actions: [], search: null, from, to }, 1, 10); + expect(results).to.be.an(Array); + expect(results.length).to.be.above(0); + expect(results.some((r) => r.id === eventId)).to.be(true); + }); + + it('listPaged returns empty with from after event', async function () { + const event = await eventlog.get(eventId); + const from = new Date(event.creationTime.getTime() + 86400000); // 1 day after + + const results = await eventlog.listPaged({ actions: [], search: null, from }, 1, 10); + expect(results.some((r) => r.id === eventId)).to.be(false); + }); + let loginEventId; it('upsert with no existing entry succeeds', async function () { const result = await eventlog.upsertLoginEvent('user.login', { ip: '1.2.3.4' }, { appId: 'thatapp' }); @@ -121,7 +141,7 @@ describe('Eventlog', function () { const id = await eventlog.add(eventlog.ACTION_USER_LOGIN, { ip: '1.2.3.4' }, { appId: 'thatapp' }); await eventlog.cleanup({ creationTime: new Date(Date.now() - 1000) }); // 1 second ago - let results = await eventlog.listPaged([], null, 1, 100); + let results = await eventlog.listPaged({ actions: [], search: null }, 1, 100); expect(results.length).to.be(1); expect(results[0].id).to.be(id); diff --git a/src/test/validator-test.js b/src/test/validator-test.js index adbdcde60..1a571e1f9 100644 --- a/src/test/validator-test.js +++ b/src/test/validator-test.js @@ -28,4 +28,20 @@ describe('Validator', function () { for (const badEmail of badEmails) { it(`isEmail returns false ${badEmail}`, () => expect(validator.isEmail(badEmail)).to.be(false)); } + + describe('isIsoDate', function () { + it('returns true for valid ISO string', function () { + expect(validator.isIsoDate('2024-01-15T10:30:00.000Z')).to.be(true); + }); + + it('returns false for invalid string', function () { + expect(validator.isIsoDate('not-a-date')).to.be(false); + }); + + it('returns false for empty or non-string', function () { + expect(validator.isIsoDate('')).to.be(false); + expect(validator.isIsoDate(null)).to.be(false); + expect(validator.isIsoDate(undefined)).to.be(false); + }); + }); }); diff --git a/src/validator.js b/src/validator.js index cd9946796..6d824c23f 100644 --- a/src/validator.js +++ b/src/validator.js @@ -10,6 +10,14 @@ function isEmail(email) { return emailRegex.test(email); } +function isIsoDate(value) { + assert.strictEqual(typeof value, 'string'); + + const date = new Date(value); + return !Number.isNaN(date.getTime()); +} + export default { - isEmail + isEmail, + isIsoDate };