UNPKG

@flowfuse/flowfuse

Version:

An open source low-code development platform

488 lines (468 loc) • 18 kB
const { Op } = require('sequelize') const { Roles } = require('../../lib/roles.js') module.exports = async function (app) { async function getStats () { const userCount = await app.db.models.User.count({ attributes: ['admin'], group: 'admin' }) const projectStateCounts = await app.db.models.Project.count({ attributes: ['state'], group: 'state' }) const teamTypeCounts = await app.db.models.Team.count({ attributes: ['TeamTypeId'], group: 'TeamTypeId' }) const teamTypes = await app.db.models.TeamType.findAll({ attributes: ['id', 'name'] }) const teamTypesMap = {} teamTypes.forEach(tt => { teamTypesMap[tt.id] = tt.name }) const license = await app.license.get() || app.license.defaults const result = { userCount: 0, maxUsers: license.users, adminCount: 0, inviteCount: await app.db.models.Invitation.count(), teamCount: await app.db.models.Team.count(), maxTeams: license.teams, teamsByType: {}, instanceCount: 0, maxInstances: license.projects || license.instances, instancesByState: {}, deviceCount: await app.db.models.Device.count(), devicesByMode: {} } if (Object.hasOwn(license, 'devices')) { result.maxDevices = license.devices } if (app.license.active()) { const { mqttClients } = await app.license.usage('mqttClients') result.maxMqttClients = mqttClients.limit result.mqttClientCount = mqttClients.count } userCount.forEach(u => { result.userCount += u.count if (u.admin) { result.adminCount = u.count } }) projectStateCounts.forEach(projectState => { result.instanceCount += projectState.count result.instancesByState[projectState.state] = projectState.count }) teamTypeCounts.forEach(teamTypeCount => { result.teamsByType[teamTypesMap[teamTypeCount.TeamTypeId]] = teamTypeCount.count }) const deviceModeCounts = await app.db.models.Device.count({ attributes: ['mode'], group: 'mode' }) deviceModeCounts.forEach(mode => { result.devicesByMode[mode.mode] = mode.count }) const now = Date.now() const devicesByLastSeenNever = await app.db.models.Device.count({ where: { lastSeenAt: null } }) const devicesByLastSeenDay = await app.db.models.Device.count({ where: { lastSeenAt: { [Op.gte]: new Date(now - 1000 * 60 * 60 * 24) } } }) const devicesByLastSeenWeek = await app.db.models.Device.count({ where: { lastSeenAt: { [Op.gte]: new Date(now - 1000 * 60 * 60 * 24 * 7) } } }) const devicesByLastSeenMonth = await app.db.models.Device.count({ where: { lastSeenAt: { [Op.gte]: new Date(now - 1000 * 60 * 60 * 24 * 7 * 4) } } }) result.devicesByLastSeen = { never: devicesByLastSeenNever, day: devicesByLastSeenDay, week: devicesByLastSeenWeek - devicesByLastSeenDay, month: devicesByLastSeenMonth - devicesByLastSeenWeek, older: result.deviceCount - devicesByLastSeenNever - devicesByLastSeenMonth } if (app.billing) { const teamStateCounts = await app.db.models.Subscription.count({ attributes: ['status'], group: 'status' }) result.teamsByBillingState = {} teamStateCounts.forEach(teamState => { result.teamsByBillingState[teamState.status] = teamState.count }) const trialStats = await app.db.models.Subscription.count({ where: { status: 'trial' }, attributes: ['trialStatus'], group: 'trialStatus' }) result.trialsByState = {} trialStats.forEach(trialStat => { result.trialsByState[trialStat.trialStatus] = trialStat.count }) } return result } /** * Converts a JSON Object to a flattened object with key names more aligned * with OpenMetrics format * ``` * { * propertyOne: 1, * propertyTwo: { * colour: 'red' * } * } * ``` * becomes * ``` * { * property_one: 1, * property_two_colour: 'red' * } * ``` * @param {string} root the root of the new key name to apply * @param {Object} obj the object to flatten * @returns a flattened object */ function flattenObject (root, obj) { let result = {} const rootKey = root ? `${root}_` : '' for (const [key, value] of Object.entries(obj)) { const formattedKey = `${rootKey}${key}`.replace(/[A-Z]/g, m => { return '_' + m.toLowerCase() }) if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { result[formattedKey] = value } else { const values = flattenObject(formattedKey, obj[key]) result = { ...result, ...values } } } return result } /** * Stringifies a JSON Object to OpenMetrics format * @param {Object} stats the object to conver * @returns a OpenMetrics formatted version of the object */ function convertToOpenMetrics (stats) { const result = flattenObject('flowforge', stats) const lines = Object.entries(result).map(([key, value]) => `${key} ${value}`) return lines.join('\n') + '\n' } app.get('/stats', { preHandler: app.needsPermission('platform:stats'), config: { rateLimit: app.config.rate_limits }, schema: { summary: 'Get a platform stats - admin-only', tags: ['Platform'], response: { 200: { content: { 'application/json': { schema: { type: 'object', additionalProperties: true } }, 'application/openmetrics-text': { schema: { type: 'string' } } } }, '4xx': { $ref: 'APIError' } } } }, async (request, reply) => { const stats = await getStats() if (request.headers.accept?.includes('application/openmetrics-text')) { reply.send(convertToOpenMetrics(stats)) } else { reply.send(stats) } }) app.get('/license', { preHandler: app.needsPermission('license:read'), schema: { summary: 'Get a platform license - admin-only', tags: ['Platform'], response: { 200: { type: 'object', additionalProperties: true }, '4xx': { $ref: 'APIError' } } } }, async (request, reply) => { reply.send(app.license.get() || {}) }) app.put('/license', { preHandler: app.needsPermission('license:edit'), schema: { summary: 'Apply a platform license - admin-only', tags: ['Platform'], body: { type: 'object', required: ['license', 'action'], properties: { license: { type: 'string' }, action: { type: 'string' } } }, response: { 200: { type: 'object', additionalProperties: true }, '4xx': { $ref: 'APIError' } } } }, async (request, reply) => { try { if (request.body.action === 'apply') { await app.license.apply(request.body.license) const license = app.license.get() || {} await app.auditLog.Platform.platform.license.applied(request.session.User, null, license) reply.send(license) } else if (request.body.action === 'inspect') { const license = await app.license.inspect(request.body.license) await app.auditLog.Platform.platform.license.inspected(request.session.User, null, license) reply.send(license) } else { reply.code(400).send({ code: 'invalid_license_action', error: 'Invalid action' }) } } catch (err) { let responseMessage = err.toString() if (/malformed/.test(responseMessage)) { responseMessage = 'Failed to parse license' } const resp = { code: 'invalid_license', error: responseMessage } if (request.body.action === 'apply') { await app.auditLog.Platform.platform.license.applied(request.session.User, resp, request.body.license) } else if (request.body.action === 'inspect') { await app.auditLog.Platform.platform.license.inspected(request.session.User, resp, request.body.license) } reply.code(400).send(resp) } }) app.get('/invitations', { preHandler: app.needsPermission('invitation:list'), schema: { summary: 'Get a list of all invitations - admin-only', tags: ['Platform'], response: { 200: { type: 'object', properties: { // meta: { $ref: 'PaginationMeta' }, count: { type: 'number' }, invitations: { $ref: 'InvitationList' } } }, '4xx': { $ref: 'APIError' } } } }, async (request, reply) => { // TODO: Pagination const invitations = await app.db.models.Invitation.get() const result = app.db.views.Invitation.invitationList(invitations) reply.send({ meta: {}, // For future pagination count: result.length, invitations: result }) }) /** * Get platform audit logs * @name /api/v1/admin/audit-log * @memberof forge.routes.api.admin */ app.get('/audit-log', { preHandler: app.needsPermission('platform:audit-log'), schema: { summary: 'Get platform audit event entries - admin-only', tags: ['Platform'], query: { allOf: [ { $ref: 'PaginationParams' }, { $ref: 'AuditLogQueryParams' } ] }, response: { 200: { type: 'object', properties: { meta: { $ref: 'PaginationMeta' }, count: { type: 'number' }, log: { $ref: 'AuditLogEntryList' } } }, '4xx': { $ref: 'APIError' } } } }, async (request, reply) => { const paginationOptions = app.getPaginationOptions(request) const logEntries = await app.db.models.AuditLog.forPlatform(paginationOptions) const result = app.db.views.AuditLog.auditLog(logEntries) reply.send(result) }) /** * Get platform audit logs as CSV * @name /api/v1/admin/audit-log/export * @memberof forge.routes.api.admin */ app.get('/audit-log/export', { preHandler: app.needsPermission('platform:audit-log'), schema: { summary: 'Gets platform audit events as CSV - admin-only', tags: ['Platform'], query: { allOf: [ { $ref: 'PaginationParams' }, { $ref: 'AuditLogQueryParams' } ] }, response: { 200: { content: { 'text/csv': { schema: { type: 'string' } } } }, '4xx': { $ref: 'APIError' } } } }, async (request, reply) => { const paginationOptions = app.getPaginationOptions(request) const logEntries = await app.db.models.AuditLog.forPlatform(paginationOptions) const result = app.db.views.AuditLog.auditLog(logEntries) reply.type('text/csv').send([ ['id', 'event', 'body', 'scope', 'trigger', 'createdAt'], ...result.log.map(row => [ row.id, row.event, `"${row.body ? JSON.stringify(row.body).replace(/"/g, '""') : ''}"`, `"${JSON.stringify(row.scope).replace(/"/g, '""')}"`, `"${JSON.stringify(row.trigger).replace(/"/g, '""')}"`, row.createdAt?.toISOString() ]) ] .map(row => row.join(',')) .join('\r\n')) }) app.post('/stats-token', { preHandler: app.needsPermission('platform:stats:token'), schema: { summary: 'Regenerate platform stats access token - admin-only', tags: ['Platform'], response: { 200: { type: 'object', properties: { token: { type: 'string' } } }, '4xx': { $ref: 'APIError' } } } }, async (request, reply) => { const token = await app.db.controllers.AccessToken.generatePlatformStatisticsToken(request.session.User) reply.send(token) }) app.delete('/stats-token', { preHandler: app.needsPermission('platform:stats:token'), schema: { summary: 'Remove platform stats access token - admin-only', tags: ['Platform'], response: { 200: { $ref: 'APIStatus' }, '4xx': { $ref: 'APIError' } } } }, async (request, reply) => { await app.db.controllers.AccessToken.removePlatformStatisticsToken() reply.send({ status: 'okay' }) }) app.post('/announcements', { preHandler: app.needsPermission('user:announcements:manage'), schema: { summary: 'Send platform wide announcements', tags: ['Platform', 'Notifications', 'Announcements'], body: { type: 'object', required: ['message', 'title', 'filter'], properties: { message: { type: 'string' }, title: { type: 'string' }, filter: { type: 'object', properties: { roles: { type: 'array', items: { type: 'number' } } } }, mock: { type: 'boolean' }, to: { type: 'object' }, url: { type: 'string' } } }, response: { 200: { type: 'object', properties: { recipientCount: { type: 'number' }, mock: { type: 'boolean' } } }, '4xx': { $ref: 'APIError' } } } }, async (request, reply) => { const { title, message, filter, mock, to, url } = request.body const recipientRoles = filter?.roles if (recipientRoles && !recipientRoles.every(value => Object.values(Roles).includes(value))) { return reply.code(400).send({ code: 'bad_request', error: 'Invalid Role provided.' }) } let teamTypes if (filter?.teamTypes && filter.teamTypes.length > 0) { teamTypes = filter.teamTypes.map(app.db.models.TeamType.decodeHashid).flat() } let billing if (filter?.billing && filter.billing.length > 0) { billing = filter.billing } if (mock) { // If mock is sent, return an indication of how many users would receive this notification // without actually sending them. const count = await app.db.models.User.byTeamRole(recipientRoles, { teamTypes, billing, summary: true, count: true }) reply.send({ mock: true, recipientCount: count }) return } const recipients = await app.db.models.User.byTeamRole(recipientRoles, { teamTypes, billing, summary: true }) const notificationType = 'announcement' const titleSlug = title.replace(/[^a-zA-Z0-9-]/g, '-').toLowerCase() const uniqueId = Date.now().toString(36) + Math.random().toString(36).substring(2) const reference = `${uniqueId}:${titleSlug}` const data = { title, message, ...(to && { to }), ...(url && { url }) } await app.notifications.sendBulk( recipients, notificationType, data, reference ) reply.send({ recipientCount: recipients.length }) }) }