diff --git a/admin/src/components/signup-list.tsx b/admin/src/components/signup-list.tsx index 60401f23..c24b7e43 100644 --- a/admin/src/components/signup-list.tsx +++ b/admin/src/components/signup-list.tsx @@ -38,7 +38,7 @@ enum TableAction { const ITEMS_PER_PAGE = 50 -const SEARCH_FILTER_PATTERN = /(!?\w+):([^ ]+)/g +const SEARCH_FILTER_PATTERN = /(!?[a-z.]*):([^ ]+)/g function parseSearchFilter(filter: string) { const filters: SignupListFilter[] = [] diff --git a/db/seeders/users-data.js b/db/seeders/users-data.js index 96e643c6..fdb63cf1 100644 --- a/db/seeders/users-data.js +++ b/db/seeders/users-data.js @@ -184,7 +184,7 @@ module.exports = { phone_code_attempts: 0, phone_code: null, ip: '75.662.77.122', - fingerprint: '{"date": "Fri Sep 15 2017 10:38:36 GMT+0200 (Paris, Madrid (heure d’été)", "device": {"renderer": "ANGLE (Intel(R) HD Graphics 4000 Direct3D11 vs_5_0 ps_5_0)", "vendor": "Google Inc."}, "lang": "fr-FR,fr,en-US,en,ms", "ref": "https://jsfiddle.net/", "ua": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.113 Safari/537.36"}', + fingerprint: '{"date": "Fri Sep 15 2017 10:38:36 GMT+0200 (Paris, Madrid (heure d’été)", "device": {"renderer": "ANGLE (Intel(R) HD Graphics 4000 Direct3D11 vs_5_0 ps_5_0)", "vendor": "Google Inc."}, "lang": "fr-FR,fr,en-US,en,ms", "ref": "https://vvork.com/", "ua": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.113 Safari/537.36"}', creation_hash: '5a234b0a964be4a73c0bf78df675038e1e297c4726cd7340bfeeaf036ceeb885', metadata: JSON.stringify({ query: { uid: '444' } }), account_is_created: false, @@ -205,7 +205,7 @@ module.exports = { phone_code_attempts: 0, phone_code: '5555', ip: '75.662.77.122', - fingerprint: '{"date": "Fri Sep 15 2017 10:38:36 GMT+0200 (Paris, Madrid (heure d’été)", "device": {"renderer": "ANGLE (Intel(R) HD Graphics 4000 Direct3D11 vs_5_0 ps_5_0)", "vendor": "Google Inc."}, "lang": "fr-FR,fr,en-US,en,ms", "ref": "https://jsfiddle.net/", "ua": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.113 Safari/537.36"}', + fingerprint: '{"date": "Fri Sep 15 2017 10:38:36 GMT+0200 (Paris, Madrid (heure d’été)", "device": {"renderer": "ANGLE (Intel(R) HD Graphics 4000 Direct3D11 vs_5_0 ps_5_0)", "vendor": "Google Inc."}, "lang": "fr-FR,fr,en-US,en,ms", "ref": "https://google.com/", "ua": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.113 Safari/537.36"}', creation_hash: '5a234b0a964be4a73c0bf78df675038e1e297c4726cd7340bfeeaf036ceeb885', metadata: JSON.stringify({ query: { uid: '555' } }), account_is_created: false, @@ -226,7 +226,7 @@ module.exports = { phone_code_attempts: 1, phone_code: '66666', ip: '75.662.77.122', - fingerprint: '{"date": "Fri Sep 15 2017 10:38:36 GMT+0200 (Paris, Madrid (heure d’été)", "device": {"renderer": "ANGLE (Intel(R) HD Graphics 4000 Direct3D11 vs_5_0 ps_5_0)", "vendor": "Google Inc."}, "lang": "fr-FR,fr,en-US,en,ms", "ref": "https://jsfiddle.net/", "ua": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.113 Safari/537.36"}', + fingerprint: '{"date": "Fri Sep 15 2017 10:38:36 GMT+0200 (Paris, Madrid (heure d’été)", "device": {"renderer": "hello world INC 2018", "vendor": "Cool` Inc."}, "lang": "fr-FR,fr,en-US,en,ms", "ref": "https://steemit.com/", "ua": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.113 Safari/537.36"}', creation_hash: '5a234b0a964be4a73c0bf78df675038e1e297c4726cd7340bfeeaf036ceeb885', metadata: JSON.stringify({ query: { uid: '666' } }), account_is_created: false, diff --git a/helpers/database.js b/helpers/database.js index d8bd13ac..78b75568 100644 --- a/helpers/database.js +++ b/helpers/database.js @@ -45,6 +45,8 @@ async function emailIsInUse(email) { const logAction = async data => db.actions.create(data); const findUser = async where => db.users.findOne(where); +const findUsers = async query => db.users.findAll(query); +const countUsers = async where => db.users.count({ where }); const usernameIsBooked = async username => { const user = await findUser({ @@ -83,6 +85,8 @@ module.exports = { emailIsInUse, logAction, findUser, + findUsers, + countUsers, usernameIsBooked, createUser, phoneIsInUse, diff --git a/routes/admin.js b/routes/admin.js index daed302a..35b0b004 100644 --- a/routes/admin.js +++ b/routes/admin.js @@ -5,6 +5,7 @@ const db = require('./../db/models'); const geoip = require('../helpers/maxmind'); const services = require('../helpers/services'); const { OAuth2Client } = require('google-auth-library'); +const adminHandlers = require('./adminHandlers'); const { Sequelize } = db; @@ -168,102 +169,7 @@ addHandler('/get_signup', async req => { return { user, actions, location }; }); -addHandler('/list_signups', async req => { - const { limit, order, offset, filters } = req.body; - const query = { - order: order || [['created_at', 'DESC']], - limit: limit ? Math.min(limit, 100) : 10, - }; - if (offset) { - query.offset = offset; - } - if (Array.isArray(filters) && filters.length > 0) { - const { - or, - eq, - ne, - like, - notLike, - and, - gte, - lte, - regexp, - notRegexp, - } = Sequelize.Op; - const andList = []; - for (const filter of filters) { - // eslint-disable-line - const { value } = filter; - let name = filter.name; - let negate = false; - if (name[0] === '!') { - negate = true; - name = name.slice(1); - } - const nLike = negate ? notLike : like; - const nEq = negate ? ne : eq; - const nRegexp = negate ? notRegexp : regexp; - switch (name) { - case 'text': - andList.push({ - [negate ? and : or]: [ - { email: { [nLike]: `%${value}%` } }, - { username: { [nLike]: `%${value}%` } }, - { phone_number: { [nLike]: `%${value}%` } }, - { fingerprint: { [nLike]: `%${value}%` } }, - ], - }); - break; - case 'status': - andList.push({ status: { [nEq]: value } }); - break; - case 'ip': - andList.push({ ip: { [nEq]: value } }); - break; - case 'username': - andList.push({ username: { [nEq]: value } }); - break; - case 'phone': - case 'phone_number': - andList.push({ phone_number: { [nEq]: value } }); - break; - case 'email': - andList.push({ email: { [nEq]: value } }); - break; - case 'username_re': - andList.push({ username: { [nRegexp]: value } }); - break; - case 'email_re': - andList.push({ email: { [nRegexp]: value } }); - break; - case 'phone_re': - case 'phone_number_re': - andList.push({ phone_number: { [nRegexp]: value } }); - break; - case 'note': - andList.push({ review_note: { [nLike]: `%${value}%` } }); - break; - case 'note_re': - andList.push({ review_note: { [nRegexp]: value } }); - break; - case 'from': - andList.push({ created_at: { [gte]: new Date(value) } }); - break; - case 'to': - andList.push({ created_at: { [lte]: new Date(value) } }); - break; - default: - throw new Error(`Unknown filter: ${name}`); - } - } - query.where = { [and]: andList }; - } - const [total, users] = await Promise.all([ - db.users.count({ where: query.where }), - db.users.findAll(query), - ]); - return { total, users, query }; -}); +addHandler('/list_signups', adminHandlers.listSignups); addHandler('/approve_signups', async req => { const { ids } = req.body; diff --git a/routes/adminHandlers.js b/routes/adminHandlers.js new file mode 100644 index 00000000..c3145f32 --- /dev/null +++ b/routes/adminHandlers.js @@ -0,0 +1,134 @@ +const database = require('../helpers/database'); + +const { Sequelize } = require('../db/models'); + +async function listSignups(req) { + const { limit, order, offset, filters } = req.body; + const query = { + order: order || [['created_at', 'DESC']], + limit: limit ? Math.min(limit, 100) : 10, + }; + if (offset) { + query.offset = offset; + } + if (Array.isArray(filters) && filters.length > 0) { + const { + where, + literal, + Op: { or, eq, ne, like, notLike, and, gte, lte, regexp, notRegexp }, + } = Sequelize; + + const andList = []; + for (const filter of filters) { + // eslint-disable-line + const { value } = filter; + let name = filter.name; + let negate = false; + if (name[0] === '!') { + negate = true; + name = name.slice(1); + } + const nLike = negate ? notLike : like; + const nEq = negate ? ne : eq; + const nRegexp = negate ? notRegexp : regexp; + switch (name) { + case 'text': + andList.push({ + [negate ? and : or]: [ + { email: { [nLike]: `%${value}%` } }, + { username: { [nLike]: `%${value}%` } }, + { phone_number: { [nLike]: `%${value}%` } }, + ], + }); + break; + case 'status': + andList.push({ status: { [nEq]: value } }); + break; + case 'ip': + andList.push({ ip: { [nEq]: value } }); + break; + case 'username': + andList.push({ username: { [nEq]: value } }); + break; + case 'phone': + case 'phone_number': + andList.push({ phone_number: { [nEq]: value } }); + break; + case 'email': + andList.push({ email: { [nEq]: value } }); + break; + case 'username_re': + andList.push({ username: { [nRegexp]: value } }); + break; + case 'email_re': + andList.push({ email: { [nRegexp]: value } }); + break; + case 'phone_re': + case 'phone_number_re': + andList.push({ phone_number: { [nRegexp]: value } }); + break; + case 'note': + andList.push({ review_note: { [nLike]: `%${value}%` } }); + break; + case 'note_re': + andList.push({ review_note: { [nRegexp]: value } }); + break; + case 'from': + andList.push({ created_at: { [gte]: new Date(value) } }); + break; + case 'to': + andList.push({ created_at: { [lte]: new Date(value) } }); + break; + case 'fingerprint.ua': + andList.push({ + fingerprint: where(literal("fingerprint -> '$.ua'"), { + [nRegexp]: value, + }), + }); + break; + case 'fingerprint.ref': + andList.push({ + fingerprint: where(literal("fingerprint -> '$.ref'"), { + [nRegexp]: value, + }), + }); + break; + case 'fingerprint.lang': + andList.push({ + fingerprint: where(literal("fingerprint -> '$.lang'"), { + [nRegexp]: value, + }), + }); + break; + case 'fingerprint.device.vendor': + andList.push({ + fingerprint: where( + literal("fingerprint -> '$.device.vendor'"), + { [nRegexp]: value } + ), + }); + break; + case 'fingerprint.device.renderer': + andList.push({ + fingerprint: where( + literal("fingerprint -> '$.device.renderer'"), + { [nRegexp]: value } + ), + }); + break; + default: + throw new Error(`Unknown filter: ${name}`); + } + } + query.where = { [and]: andList }; + } + const [total, users] = await Promise.all([ + database.countUsers(query.where), + database.findUsers(query), + ]); + return { total, users, query }; +} + +module.exports = { + listSignups, +}; diff --git a/routes/adminHandlers.test.js b/routes/adminHandlers.test.js new file mode 100644 index 00000000..d0c1fd9f --- /dev/null +++ b/routes/adminHandlers.test.js @@ -0,0 +1,143 @@ +const { Sequelize } = require('./../db/models'); + +const { Op: { or, eq, ne, like, and, gte, lte, regexp } } = Sequelize; + +describe('adminHandlers listSignups', () => { + beforeEach(() => { + jest.resetModules(); + }); + it('should find and count users', async () => { + jest.mock('../helpers/database'); + const adminHandlers = require('./adminHandlers'); + const mockDb = require('../helpers/database'); + adminHandlers.listSignups({ + body: { + limit: 60, + order: 'fun', + offset: 30, + filters: [{ name: 'status', value: 'test' }], + }, + }); + expect(mockDb.findUsers.mock.calls.length).toBe(1); + expect(mockDb.countUsers.mock.calls.length).toBe(1); + }); + it('negates correctly when search text is prepended with !', async () => { + const adminHandlers = require('./adminHandlers'); + const ret = await adminHandlers.listSignups({ + body: { + limit: 60, + order: 'fun', + offset: 30, + filters: [ + { name: 'status', value: 'status_test' }, + { name: '!status', value: 'ne_status_test' }, + ], + }, + }); + const q = ret.query.where[and]; + expect(q[0].status[eq]).toBe('status_test'); + expect(q[1].status[ne]).toBe('ne_status_test'); + }); + it('throws an error when unknown filter is passed', async () => { + const adminHandlers = require('./adminHandlers'); + try { + await adminHandlers.listSignups({ + body: { + limit: 60, + order: 'fun', + offset: 30, + filters: [{ name: 'idk', value: 'nope' }], + }, + }); + } catch (e) { + expect(e).toEqual(new Error('Unknown filter: idk')); + } + }); + + it('creates the correct query for an array of different filters', async () => { + const adminHandlers = require('./adminHandlers'); + const ret = await adminHandlers.listSignups({ + body: { + limit: 60, + order: 'fun', + offset: 30, + filters: [ + { name: 'text', value: 'text_test' }, + { name: 'status', value: 'status_test' }, + { name: '!status', value: 'ne_status_test' }, + { name: 'ip', value: 'ip_test' }, + { name: 'phone', value: 'phone_test' }, + { name: 'email', value: 'email_test' }, + { name: 'username_re', value: 'username_re_test' }, + { name: 'email_re', value: 'email_re_test' }, + { name: 'phone_re', value: 'phone_re_test' }, + { name: 'note', value: 'note_test' }, + { name: 'note_re', value: 'note_re_test' }, + { name: 'from', value: 'December 17, 1995 03:24:00' }, + { name: 'to', value: 'December 17, 1995 04:24:00' }, + { + name: 'fingerprint.ua', + value: 'fingerprint_ua_test', + }, + { + name: 'fingerprint.ref', + value: 'fingerprint_ref_test', + }, + { + name: 'fingerprint.lang', + value: 'fingerprint_lang_test', + }, + { + name: 'fingerprint.device.vendor', + value: 'fingerprint_vendor_test', + }, + { + name: 'fingerprint.device.renderer', + value: 'fingerprint_renderer_test', + }, + ], + }, + }); + const q = ret.query.where[and]; + expect(q[0][or][0].email[like]).toBe('%text_test%'); + expect(q[0][or][1].username[like]).toBe('%text_test%'); + expect(q[0][or][2].phone_number[like]).toBe('%text_test%'); + expect(q[1].status[eq]).toBe('status_test'); + expect(q[2].status[ne]).toBe('ne_status_test'); + expect(q[3].ip[eq]).toBe('ip_test'); + expect(q[4].phone_number[eq]).toBe('phone_test'); + expect(q[5].email[eq]).toBe('email_test'); + expect(q[6].username[regexp]).toBe('username_re_test'); + expect(q[7].email[regexp]).toBe('email_re_test'); + expect(q[8].phone_number[regexp]).toBe('phone_re_test'); + expect(q[9].review_note[like]).toBe('%note_test%'); + expect(q[10].review_note[regexp]).toBe('note_re_test'); + expect(q[11].created_at[gte]).toBeInstanceOf(Date); + expect(q[12].created_at[lte]).toBeInstanceOf(Date); + expect(q[13].fingerprint).toEqual({ + attribute: { val: "fingerprint -> '$.ua'" }, + comparator: '=', + logic: { [regexp]: 'fingerprint_ua_test' }, + }); + expect(q[14].fingerprint).toEqual({ + attribute: { val: "fingerprint -> '$.ref'" }, + comparator: '=', + logic: { [regexp]: 'fingerprint_ref_test' }, + }); + expect(q[15].fingerprint).toEqual({ + attribute: { val: "fingerprint -> '$.lang'" }, + comparator: '=', + logic: { [regexp]: 'fingerprint_lang_test' }, + }); + expect(q[16].fingerprint).toEqual({ + attribute: { val: "fingerprint -> '$.device.vendor'" }, + comparator: '=', + logic: { [regexp]: 'fingerprint_vendor_test' }, + }); + expect(q[17].fingerprint).toEqual({ + attribute: { val: "fingerprint -> '$.device.renderer'" }, + comparator: '=', + logic: { [regexp]: 'fingerprint_renderer_test' }, + }); + }); +});