UNPKG

@flowfuse/flowfuse

Version:

An open source low-code development platform

487 lines (456 loc) • 18.2 kB
/** * Application DeviceGroup api routes * * - /api/v1/applications/:applicationId/device-groups * * @namespace application * @memberof forge.routes.api */ const { ValidationError } = require('sequelize') const { UpdatesCollection } = require('../../../auditLog/formatters.js') const { Roles } = require('../../../lib/roles.js') const { DeviceGroupMembershipValidationError } = require('../../db/controllers/DeviceGroup.js') // Declare getLogger function to provide type hints / quick code nav / code completion /** @type {import('../../../../forge/auditLog/application').getLoggers} */ const getApplicationLogger = (app) => { return app.auditLog.Application } /** * @param {import('../../../forge.js').ForgeApplication} app The application instance */ module.exports = async function (app) { const deviceGroupLogger = getApplicationLogger(app).application.deviceGroup // pre-handler for all routes in this file app.addHook('preHandler', async (request, reply) => { // Get the application const applicationId = request.params.applicationId if (!applicationId) { return reply.code(404).send({ code: 'not_found', error: 'Not Found' }) } try { request.application = await app.db.models.Application.byId(applicationId) if (!request.application) { return reply.code(404).send({ code: 'not_found', error: 'Not Found' }) } if (request.session.User) { request.teamMembership = await request.session.User.getTeamMembership(request.application.Team.id) if (!request.teamMembership && !request.session.User.admin) { return reply.code(404).send({ code: 'not_found', error: 'Not Found' }) } } const teamType = await request.application.Team.getTeamType() if (!teamType.getFeatureProperty('deviceGroups', false)) { return reply.code(404).send({ code: 'not_found', error: 'Not Found' }) } } catch (err) { return reply.code(500).send({ code: 'unexpected_error', error: err.toString() }) } // Get the device group const groupId = request.params.groupId if (groupId) { request.deviceGroup = await app.db.models.DeviceGroup.byId(groupId) if (!request.deviceGroup || request.deviceGroup.ApplicationId !== request.application.id) { reply.code(404).send({ code: 'not_found', error: 'Not Found' }) } } }) /** * Get a list of device groups in an application * @method GET * @name /api/v1/applications/:applicationId/device-groups * @memberof forge.routes.api.application */ app.get('/', { preHandler: app.needsPermission('application:device-group:list'), schema: { summary: 'Get a list of device groups in an application', tags: ['Application Device Groups'], query: { $ref: 'PaginationParams' }, params: { type: 'object', properties: { applicationId: { type: 'string' } } }, response: { 200: { type: 'object', properties: { meta: { $ref: 'PaginationMeta' }, count: { type: 'number' }, groups: { type: 'array', items: { $ref: 'DeviceGroupSummary' } } } }, '4xx': { $ref: 'APIError' } } } }, async (request, reply) => { const paginationOptions = app.db.controllers.Device.getDevicePaginationOptions(request) const where = { ApplicationId: request.application.hashid } const groupData = await app.db.models.DeviceGroup.getAll(paginationOptions, where) const result = { count: groupData.count, meta: groupData.meta, groups: (groupData.groups || []).map(d => app.db.views.DeviceGroup.deviceGroupSummary(d, { includeApplication: false })) } reply.send(result) }) /** * Add a new Device Group to an Application * @method POST * @name /api/v1/applications/:applicationId/device-groups * @memberof forge.routes.api.application */ app.post('/', { preHandler: app.needsPermission('application:device-group:create'), schema: { summary: 'Add a new Device Group to an Application', tags: ['Application Device Groups'], body: { type: 'object', properties: { name: { type: 'string' }, description: { type: 'string' } }, required: ['name'] }, params: { type: 'object', properties: { applicationId: { type: 'string' } } }, response: { 201: { $ref: 'DeviceGroupSummary' }, '4xx': { $ref: 'APIError' } } } }, async (request, reply) => { const application = request.application const name = request.body.name const description = request.body.description try { const newGroup = await app.db.controllers.DeviceGroup.createDeviceGroup(name, { application, description }) const newGroupView = app.db.views.DeviceGroup.deviceGroupSummary(newGroup) await deviceGroupLogger.created(request.session.User, null, application, newGroup) reply.code(201).send(newGroupView) } catch (error) { return handleError(error, reply) } }) /** * Update a Device Group * @method PUT * @name /api/v1/applications/:applicationId/device-groups/:groupId * @memberof forge.routes.api.application */ app.put('/:groupId', { preHandler: app.needsPermission('application:device-group:update'), schema: { summary: 'Update a Device Group', tags: ['Application Device Groups'], body: { type: 'object', properties: { name: { type: 'string' }, description: { type: 'string' }, targetSnapshotId: { type: ['string', 'null'] } } }, params: { type: 'object', properties: { applicationId: { type: 'string' }, groupId: { type: 'string' } } }, response: { 200: { type: 'object', additionalProperties: false }, '4xx': { $ref: 'APIError' } } } }, async (request, reply) => { const group = request.deviceGroup const name = request.body.name const description = request.body.description const targetSnapshotId = request.body.targetSnapshotId try { // gather before details for audit log const originalDetails = { name: group.name, description: group.description, targetSnapshotId: null } originalDetails.targetSnapshotId = group.targetSnapshotId ? app.db.models.ProjectSnapshot.encodeHashid(group.targetSnapshotId) : null // perform update const updatedGroup = await app.db.controllers.DeviceGroup.updateDeviceGroup(group, { name, description, targetSnapshotId }) // gather after details for audit log const newDetails = { name: updatedGroup.name, description: updatedGroup.description, targetSnapshotId: null } newDetails.targetSnapshotId = updatedGroup.targetSnapshotId ? app.db.models.ProjectSnapshot.encodeHashid(updatedGroup.targetSnapshotId) : null // log the update const updates = new UpdatesCollection() updates.pushDifferences(originalDetails, newDetails) await deviceGroupLogger.updated(request.session.User, null, request.application, group, updates) reply.send({}) } catch (error) { return handleError(error, reply) } }) /** * Get a specific Device Group * @method GET * @name /api/v1/applications/:applicationId/device-groups/:groupId * @memberof forge.routes.api.application */ app.get('/:groupId', { preHandler: app.needsPermission('application:device-group:read'), schema: { summary: 'Get a specific Device Group', tags: ['Application Device Groups'], params: { type: 'object', properties: { applicationId: { type: 'string' }, groupId: { type: 'string' } } }, response: { 200: { $ref: 'DeviceGroup' }, '4xx': { $ref: 'APIError' } } } }, async (request, reply) => { const group = request.deviceGroup // already loaded in preHandler const groupView = app.db.views.DeviceGroup.deviceGroup(group) reply.send(groupView) }) /** * Update Device Group membership * @method PATCH * @name /api/v1/applications/:applicationId/device-groups/:groupId * @memberof forge.routes.api.application */ app.patch('/:groupId', { preHandler: app.needsPermission('application:device-group:membership:update'), schema: { summary: 'Update Device Group membership', tags: ['Application Device Groups'], body: { type: 'object', properties: { add: { type: 'array', items: { type: 'string' } }, remove: { type: 'array', items: { type: 'string' } }, set: { type: 'array', items: { type: 'string' } } } }, params: { type: 'object', properties: { applicationId: { type: 'string' }, groupId: { type: 'string' } } }, response: { 200: { type: 'object', additionalProperties: false }, '4xx': { $ref: 'APIError' } } } }, async (request, reply) => { const group = request.deviceGroup const addDevices = request.body.add const removeDevices = request.body.remove const setDevices = request.body.set try { // get before state const originalDevices = await group.getDevices() const originalSorted = originalDevices.sort((a, b) => a.id - b.id) // const originalMemberList = originalSorted.map(d => d.name).join(', ') // update membership await app.db.controllers.DeviceGroup.updateDeviceGroupMembership(group, { addDevices, removeDevices, setDevices }) // get after state const newDevices = await group.getDevices() const newSorted = newDevices.sort((a, b) => a.id - b.id) // const newMembers = newSorted.map(d => d.name).join(', ') // compare original and new members, generate a list of added and a list of removed members const added = new Set() const removed = new Set() for (const device of originalSorted) { if (!newSorted.find(d => d.id === device.id)) { removed.add(device) } } for (const device of newSorted) { if (!originalSorted.find(d => d.id === device.id)) { added.add(device) } } // generate audit log message e.g. "Added 2, removed 1" const infoBuilder = [] if (added.size > 0) { infoBuilder.push(`Added ${added.size}`) } if (removed.size > 0) { infoBuilder.push(`Removed ${removed.size}`) } await deviceGroupLogger.membersChanged(request.session.User, null, request.application, group, null, { info: infoBuilder.join(', ') }) reply.send({}) } catch (err) { return handleError(err, reply) } }) /** * Delete a Device Group * @method DELETE * @name /api/v1/applications/:applicationId/device-groups/:groupId * @memberof forge.routes.api.application */ app.delete('/:groupId', { preHandler: app.needsPermission('application:device-group:delete'), schema: { summary: 'Delete a Device Group', tags: ['Application Device Groups'], params: { type: 'object', properties: { applicationId: { type: 'string' }, groupId: { type: 'string' } } }, response: { 200: { type: 'object', additionalProperties: false }, '4xx': { $ref: 'APIError' } } } }, async (request, reply) => { const group = request.deviceGroup await group.destroy() await deviceGroupLogger.deleted(request.session.User, null, request.application, group) reply.send({}) }) /** * Update Device Group Settings (Environment Variables) * @method PUT * @name /api/v1/applications/:applicationId/device-groups/:groupId/settings * @memberof forge.routes.api.application */ app.put('/:groupId/settings', { preHandler: app.needsPermission('application:device-group:update'), // re-use update permission (owner only) schema: { summary: 'Update a Device Group Settings', tags: ['Application Device Groups'], body: { type: 'object', properties: { env: { type: 'array', items: { type: 'object', additionalProperties: true } } } }, params: { type: 'object', properties: { applicationId: { type: 'string' }, groupId: { type: 'string' } } }, response: { 200: { $ref: 'APIStatus' }, '4xx': { $ref: 'APIError' } } } }, async (request, reply) => { /** @type {Model} */ const deviceGroup = request.deviceGroup const isMember = request.teamMembership.role === Roles.Member /** @type {import('../../db/controllers/DeviceGroup.js')} */ const deviceGroupController = app.db.controllers.DeviceGroup try { let bodySettings if (isMember) { bodySettings = { env: request.body.env } } else { bodySettings = request.body } // for audit log const updates = new app.auditLog.formatters.UpdatesCollection() // transform the env arrays to a map for better logging format const currentEnv = (deviceGroup.settings?.env || []).reduce((acc, e) => { acc[e.name] = e.value return acc }, {}) if (bodySettings.env) { bodySettings.env.map(env => { if (env.hidden === true && env.value === '') { // we need to re-map the hidden value so it won't get overwritten const existingVar = deviceGroup.settings.env.find(currentEnv => currentEnv.name === env.name) if (existingVar) { env.value = existingVar.value } } return env }) } const newEnv = bodySettings.env.reduce((acc, e) => { acc[e.name] = e.value return acc }, {}) updates.pushDifferences({ env: currentEnv }, { env: newEnv }) // perform update & log await deviceGroupController.updateDeviceGroup(deviceGroup, { settings: bodySettings }) if (updates.length > 0) { await deviceGroupLogger.settings.updated(request.session.User, null, request.application, deviceGroup, updates) } reply.send({}) } catch (err) { return handleError(err, reply) } }) function handleError (err, reply) { let statusCode = 500 let code = 'unexpected_error' let error = err.error || err.message || 'Unexpected error' if (err instanceof ValidationError) { statusCode = 400 if (err.errors[0]) { code = err.errors[0].path ? `invalid_${err.errors[0].path}` : 'invalid_input' error = err.errors[0].message || error } else { code = 'invalid_input' error = err.message || error } } else if (err instanceof DeviceGroupMembershipValidationError) { statusCode = err.statusCode || 400 code = err.code || 'invalid_device_group_membership' error = err.message || error } else { app.log.error('API error in application device groups:') app.log.error(err) } return reply.code(statusCode).type('application/json').send({ code, error }) } }