@flowfuse/flowfuse
Version:
An open source low-code development platform
1,102 lines (1,063 loc) • 47.8 kB
JavaScript
const { hash } = require('../../db/utils')
// eslint-disable-next-line no-unused-vars
const { DeviceTunnelManager } = require('../../ee/lib/deviceEditor/DeviceTunnelManager')
const { Roles } = require('../../lib/roles')
const DeviceActions = require('./deviceActions')
const DeviceLive = require('./deviceLive')
const DeviceSnapshots = require('./deviceSnapshots.js')
const hasProperty = (obj, key) => Object.prototype.hasOwnProperty.call(obj, key)
/**
* Project Device api routes
*
* - /api/v1/devices
*
* @namespace devices
* @memberof forge.routes.api
*/
module.exports = async function (app) {
app.addHook('preHandler', async (request, reply) => {
if (request.params.deviceId !== undefined) {
if (request.params.deviceId) {
try {
request.device = await app.db.models.Device.byId(request.params.deviceId)
if (!request.device) {
reply.code(404).send({ code: 'not_found', error: 'Not Found' })
return
}
if (request.session.User) {
request.teamMembership = await request.session.User.getTeamMembership(request.device.Team.id)
}
} catch (err) {
reply.code(404).send({ code: 'not_found', error: 'Not Found' })
}
} else {
reply.code(404).send({ code: 'not_found', error: 'Not Found' })
}
}
})
app.register(DeviceLive, { prefix: '/:deviceId/live' })
app.register(DeviceSnapshots, { prefix: '/:deviceId/snapshots' })
app.register(DeviceActions, { prefix: '/:deviceId/actions' })
/**
* Get a list of all devices
* Admin-only
* @name /api/v1/devices/
* @static
* @memberof forge.routes.api.devices
*/
app.get('/', {
preHandler: app.needsPermission('device:list'),
schema: {
summary: 'Get a list of all devices - admin-only',
tags: ['Devices'],
query: { $ref: 'PaginationParams' },
response: {
200: {
type: 'object',
properties: {
meta: { $ref: 'PaginationMeta' },
count: { type: 'number' },
devices: { type: 'array', items: { $ref: 'Device' } }
}
},
'4xx': {
$ref: 'APIError'
}
}
}
}, async (request, reply) => {
const paginationOptions = app.getPaginationOptions(request)
const devices = await app.db.models.Device.getAll(paginationOptions)
devices.devices = devices.devices.map(d => app.db.views.Device.device(d))
reply.send(devices)
})
/**
* Get the details of a device
* @name /api/v1/devices/:id
* @static
* @memberof forge.routes.api.devices
*/
app.get('/:deviceId', {
preHandler: app.needsPermission('device:read'),
schema: {
summary: 'Get details of a device',
tags: ['Devices'],
params: {
type: 'object',
properties: {
deviceId: { type: 'string' }
}
},
response: {
200: {
$ref: 'Device'
},
'4xx': {
$ref: 'APIError'
}
}
}
}, async (request, reply) => {
const result = app.db.views.Device.device(request.device)
const nrVersion = await request.device.getSetting('editor')
if (nrVersion) {
result.nrVersion = nrVersion.nodeRedVersion
}
// ingress-nginx will set a cookie on /api/v1/devices - which will cannot allow to override
// that of the device-specific cookie. So clear it out.
if (request.cookies.FFSESSION) {
reply.clearCookie('FFSESSION', { path: '/api/v1/devices/' })
}
if (result.editor && result.editor.enabled) {
if (result.editor.connected) {
if (!result.editor.local) {
// This device has the editor enabled, is connected, but not to
// this local platform instance. We need to clear the session
// cookies so the client gets routed to another platform instance
if (request.device.editorAffinity) {
// In this case, we *know* the cookie used by the device, so
// use that.
reply.setCookie('FFSESSION', request.device.editorAffinity, {
httpOnly: true,
path: request.url,
// By default, it will uriEncode the value, which changes | to %7C
// We don't want that change to happen, so provide a cleaner
// encode function
encode: s => s
})
} else {
// An older device agent doesn't tell us about its affinity cookie
// So the best we can do is clear the session cookies and have
// the load balancer pick another route to try
// We have to clear both the top level cookie and the one for
// this specific device.
reply.clearCookie('FFSESSION', { path: request.url })
}
} else {
if (!request.device.editorAffinity && request.cookies.FFSESSION) {
// Connected locally. For a legacy device agent, we need to ensure
// the affinity cookie, if present, is set on the device-scoped path.
reply.setCookie('FFSESSION', request.cookies.FFSESSION, {
httpOnly: true,
path: request.url,
// By default, it will uriEncode the value, which changes | to %7C
// We don't want that change to happen, so provide a cleaner
// encode function
encode: s => s
})
}
}
}
}
reply.send(result)
})
/**
* Create a device (including auto-provisioning and setup with One-Time-Code)
* @name /api/v1/devices
* @static
* @memberof forge.routes.api.devices
*/
app.post('/', {
preHandler: [
async (request, reply) => {
// * If this is a Device Provisioning action: verify the device has the required scope
// & session has been populated with provisioning data
// * If this is a Device setup with a One time code, verify the code is valid and return the credentials
// * If this is a User action: verify the user has the required role
if (request.session.provisioning) {
if (request.session.provisioning?.otcSetup) {
// A request is being made to setup a device using a One-Time-Code.
// NOTE: If the token (OTC) was not valid, the request would have been rejected by
// the verifySession decorator & request.session.provisioning would not be populated
// Essentially, we are good to go
return
} else {
// A device is auto-provisioning. First check the request body team matches the token
// NOTE: If the token was not valid, the request would have been rejected by
// the verifySession decorator & request.session.provisioning would not be populated
const teamOK = request.body.team && request.body.team === request.session.provisioning.team
if (teamOK) {
const hasPermission = app.needsPermission('device:provision')
await hasPermission(request, reply) // hasPermission sends the error response if required which stops the request
return
}
}
} else if (request.body?.team && request.session.User) {
// User action: check if the user is in the team and has the required role
request.teamMembership = await request.session.User.getTeamMembership(request.body.team)
const hasPermission = app.needsPermission('device:create')
await hasPermission(request, reply) // hasPermission sends the error response if required which stops the request
return
}
reply.code(401).send({ code: 'unauthorized', error: 'unauthorized' })
}
],
schema: {
summary: 'Create or provision a device',
tags: ['Devices'],
body: {
type: 'object',
oneOf: [
{
allOf: [
{ required: ['name'] },
{ required: ['team'] },
{ not: { required: ['setup'] } } // setup is not allowed when creating a device
]
},
{
allOf: [
{ required: ['setup'] }, // when provided, setup must be `true` (see enum below)
{ not: { required: ['name'] } }, // neither of name or team are allowed when setting up a device
{ not: { required: ['team'] } }
]
}
],
properties: {
name: { type: 'string' },
type: { type: 'string' },
team: { type: 'string' },
setup: { type: 'boolean', enum: [true] }, // enum only permits a value of true for setup
agentHost: { type: 'string' } // future, for audit log
}
},
response: {
200: {
type: 'object',
allOf: [{ $ref: 'Device' }],
properties: {
credentials: { type: 'object', additionalProperties: true }
}
},
'4xx': {
$ref: 'APIError'
}
}
}
}, async (request, reply) => {
const provisioningMode = !!request.session.provisioning
const otcSetupMode = request.body.setup === true && request.session.provisioning?.otcSetup === true
let team, project, application
// Additional checks. (initial membership/team/token checks done in preHandler and auth verifySession decorator)
if (provisioningMode || otcSetupMode) {
if (otcSetupMode) {
const device = await app.db.models.Device.byId(request.session.provisioning.deviceId)
if (device) {
const credentials = await device.refreshAuthTokens({ refreshOTC: false })
app.auditLog.Team.team.device.credentialsGenerated(0, null, device?.Team, device)
app.auditLog.Device.device.credentials.generated(0, null, device)
const response = app.db.views.Device.device(device)
response.credentials = credentials
return reply.send(response)
} else {
return reply.code(404).send({ code: 'not_found', error: 'Not Found' })
}
} else {
team = await app.db.models.Team.byId(request.session.provisioning.team)
if (!team) {
reply.code(400).send({ code: 'invalid_team', error: 'Invalid team' })
return
}
if (request.session.provisioning.application) {
application = await app.db.models.Application.byId(request.session.provisioning.application)
if (!application) {
reply.code(400).send({ code: 'invalid_application', error: 'Invalid application' })
return
}
const applicationTeam = await application.getTeam()
if (!applicationTeam?.id || applicationTeam.id !== team.id) {
reply.code(400).send({ code: 'invalid_application', error: 'Invalid application' })
return
}
} else if (request.session.provisioning.project) {
project = await app.db.models.Project.byId(request.session.provisioning.project)
if (!project) {
reply.code(400).send({ code: 'invalid_instance', error: 'Invalid instance' })
return
}
const projectTeam = await project.getTeam()
if (!projectTeam?.id || projectTeam.id !== team.id) {
reply.code(400).send({ code: 'invalid_instance', error: 'Invalid instance' })
return
}
}
}
} else {
// Assume membership is enough to allow project creation.
// If we have roles that limit creation, that will need to be checked here.
if (!request.teamMembership) {
reply.code(401).send({ code: 'unauthorized', error: 'Current user not in team ' + request.body.team })
return
}
const teamMembership = await request.session.User.getTeamMembership(request.body.team, true)
team = teamMembership.get('Team')
}
try {
await team.checkDeviceCreateAllowed()
} catch (err) {
return reply
.code(err.statusCode || 400)
.send({
code: err.code || 'unexpected_error',
error: err.error || err.message
})
}
try {
const actionedBy = provisioningMode ? 'system' : request.session.User
const device = await app.db.models.Device.create({
name: request.body.name,
type: request.body.type,
credentialSecret: ''
})
try {
await team.addDevice(device)
await device.reload({
include: [
{ model: app.db.models.Team }
]
})
const credentials = await device.refreshAuthTokens({ refreshOTC: true })
await app.auditLog.Team.team.device.created(actionedBy, null, team, device)
// When device provisioning: if a project was specified, add the device to the project
if (provisioningMode && application) {
await assignDeviceToApplication(device, application)
await device.save()
await device.reload({
include: [
{ model: app.db.models.Application }
]
})
await app.auditLog.Team.team.device.assigned(actionedBy, null, device.Team, device.Application, device)
await app.auditLog.Application.application.device.assigned(actionedBy, null, device.Application, device)
await app.auditLog.Device.device.assigned(actionedBy, null, device.Application, device)
} else if (provisioningMode && project) {
await assignDeviceToProject(device, project)
await device.save()
await device.reload({
include: [
{ model: app.db.models.Project }
]
})
await app.auditLog.Team.team.device.assigned(actionedBy, null, device.Team, device.Project, device)
await app.auditLog.Project.project.device.assigned(actionedBy, null, device.Project, device)
await app.auditLog.Device.device.assigned(actionedBy, null, device.Project, device)
}
const response = app.db.views.Device.device(device)
response.credentials = credentials
reply.send(response)
} finally {
if (app.license.active() && app.billing) {
await app.billing.updateTeamBillingCounts(team)
}
}
} catch (err) {
reply.code(400).send({ code: 'unexpected_error', error: err.toString() })
}
})
/**
* Delete a device
* @name /api/v1/devices
* @static
* @memberof forge.routes.api.devices
*/
app.delete('/:deviceId', {
preHandler: app.needsPermission('device:delete'),
schema: {
summary: 'Delete a device',
tags: ['Devices'],
params: {
type: 'object',
properties: {
deviceId: { type: 'string' }
}
},
response: {
200: {
$ref: 'APIStatus'
},
'4xx': {
$ref: 'APIError'
}
}
}
}, async (request, reply) => {
try {
const team = request.device.get('Team')
await team.reload({
include: [
{ model: app.db.models.TeamType }
]
})
await request.device.destroy()
await app.auditLog.Team.team.device.deleted(request.session.User, null, team, request.device)
if (app.license.active() && app.billing) {
await app.billing.updateTeamBillingCounts(team)
}
reply.send({ status: 'okay' })
} catch (err) {
reply.code(400).send({ code: 'unexpected_error', error: err.toString() })
}
})
/**
* Update a device
* @name /api/v1/devices
* @static
* @memberof forge.routes.api.devices
*/
app.put('/:deviceId', {
preHandler: app.needsPermission('device:edit'),
schema: {
summary: 'Update a device',
tags: ['Devices'],
params: {
type: 'object',
properties: {
deviceId: { type: 'string' }
}
},
body: {
type: 'object',
properties: {
name: { type: 'string' },
type: { type: 'string' },
instance: { type: 'string', nullable: true },
application: { type: 'string', nullable: true }
}
},
response: {
200: {
$ref: 'Device'
},
'4xx': {
$ref: 'APIError'
}
}
}
}, async (request, reply) => {
let sendDeviceUpdate = false
const device = request.device
/** @type {import('../../auditLog/formatters').UpdatesCollection} */
const updates = new app.auditLog.formatters.UpdatesCollection()
let assignToProject = null
let assignToApplication = null
let postOpAuditLogAction = null
if (request.body.instance !== undefined || request.body.application !== undefined) {
// ### Add/Remove device to/from project ###
const assignTo = request.body.instance ? 'instance' : (request.body.application ? 'application' : null)
if (!assignTo) {
// ### Remove device from application/project ###
const unassignApplication = request.body.application === null
const unassignInstance = request.body.instance === null
const commonUpdates = async () => {
// Clear its target snapshot, so the next time it calls home
// it will stop the current snapshot
await device.setTargetSnapshot(null)
// Since the project/application is being cleared, clear the deviceGroup
device.DeviceGroupId = null
sendDeviceUpdate = true
// disable developer mode
device.mode = 'autonomous'
await device.save()
}
if (unassignApplication && device.Application) {
const oldApplication = device.Application
// unassign from application
await device.setApplication(null)
await commonUpdates()
await app.auditLog.Team.team.device.unassigned(request.session.User, null, device?.Team, oldApplication, device)
await app.auditLog.Application.application.device.unassigned(request.session.User, null, oldApplication, device)
}
if (unassignInstance && device.Project) {
const oldProject = device.Project
// unassign from project
await device.setProject(null)
await commonUpdates()
// disable developer mode
device.mode = 'autonomous'
await device.save()
await app.auditLog.Team.team.device.unassigned(request.session.User, null, device?.Team, oldProject, device)
await app.auditLog.Project.project.device.unassigned(request.session.User, null, oldProject, device)
}
} else if (assignTo === 'instance') {
// ### Add device to instance ###
// Update includes a instance id?
if (device.Project?.id === request.body.instance) {
// Device is already assigned to this instance - nothing to do
} else {
// Check if the specified project is in the same team
assignToProject = await app.db.models.Project.byId(request.body.instance)
if (!assignToProject) {
reply.code(400).send({ code: 'invalid_instance', error: 'invalid instance' })
return
}
if (assignToProject.Team.id !== device.Team.id) {
reply.code(400).send({ code: 'invalid_instance', error: 'invalid instance' })
return
}
// Project exists and is in the right team - assign it to the project
sendDeviceUpdate = await assignDeviceToProject(device, assignToProject)
postOpAuditLogAction = 'assigned-to-project'
}
} else if (assignTo === 'application') {
// ### Add device to application ###
// Update includes a application id?
if (device.Application?.id === request.body.application) {
// Device is already assigned to this application - nothing to do
} else {
// Check if the specified application is in the same team
assignToApplication = await app.db.models.Application.byId(request.body.application)
if (!assignToApplication) {
reply.code(400).send({ code: 'invalid_application', error: 'invalid application' })
return
}
if (assignToApplication.Team.id !== device.Team.id) {
reply.code(400).send({ code: 'invalid_application', error: 'invalid application' })
return
}
// Device exists and is in the right team - assign it to the application
sendDeviceUpdate = await assignDeviceToApplication(device, assignToApplication)
postOpAuditLogAction = 'assigned-to-application'
}
}
} else {
// ### Modify device properties ###
if (request.body.targetSnapshot !== undefined && request.body.targetSnapshot !== device.targetSnapshotId) {
// get snapshot from db
const targetSnapshot = await app.db.models.ProjectSnapshot.byId(request.body.targetSnapshot, { includeFlows: false, includeSettings: false })
if (!targetSnapshot) {
reply.code(400).send({ code: 'invalid_snapshot', error: 'invalid snapshot' })
return
}
// store original value for later audit log
const originalSnapshotId = device.targetSnapshotId
// Update the targetSnapshot of the device
await app.db.models.Device.update({ targetSnapshotId: targetSnapshot.id }, {
where: {
id: device.id
}
})
await app.auditLog.Device.device.snapshot.deployed(request.session.User, null, device, targetSnapshot)
await app.auditLog.Device.device.snapshot.targetSet(request.session.User, null, device, targetSnapshot)
updates.push('targetSnapshotId', originalSnapshotId, device.targetSnapshotId)
sendDeviceUpdate = true
}
if (request.body.name !== undefined && request.body.name !== device.name) {
updates.push('name', device.name, request.body.name)
device.name = request.body.name
sendDeviceUpdate = true
}
if (request.body.type !== undefined && request.body.type !== device.type) {
updates.push('type', device.type, request.body.type)
device.type = request.body.type
sendDeviceUpdate = true
}
}
// save and send update
await device.save()
const updatedDevice = await app.db.models.Device.byId(device.id)
if (sendDeviceUpdate) {
await app.db.controllers.Device.sendDeviceUpdateCommand(updatedDevice)
}
// check post op audit log action - create audit log entry if required
switch (postOpAuditLogAction) {
case 'assigned-to-project':
await app.auditLog.Team.team.device.assigned(request.session.User, null, updatedDevice.Team, assignToProject, updatedDevice)
await app.auditLog.Project.project.device.assigned(request.session.User, null, assignToProject, updatedDevice)
await app.auditLog.Device.device.assigned(request.session.User, null, assignToProject, updatedDevice)
break
case 'assigned-to-application':
await app.auditLog.Team.team.device.assigned(request.session.User, null, updatedDevice.Team, assignToApplication, updatedDevice)
await app.auditLog.Application.application.device.assigned(request.session.User, null, assignToApplication, updatedDevice)
await app.auditLog.Device.device.assigned(request.session.User, null, assignToApplication, updatedDevice)
break
}
// fulfil team audit log updates
if (updates.length > 0) {
await app.auditLog.Team.team.device.updated(request.session.User, null, device.Team, device, updates)
}
reply.send(app.db.views.Device.device(updatedDevice))
})
app.post('/:deviceId/generate_credentials', {
preHandler: app.needsPermission('device:edit'),
schema: {
summary: 'Regenerate device credentials',
tags: ['Devices'],
params: {
type: 'object',
properties: {
deviceId: { type: 'string' }
}
},
response: {
200: {
type: 'object',
additionalProperties: true
},
'4xx': {
$ref: 'APIError'
}
}
}
}, async (request, reply) => {
const credentials = await request.device.refreshAuthTokens({ refreshOTC: true }) // refreshOTC = true to generate a new OTC
app.auditLog.Team.team.device.credentialsGenerated(request.session.User, null, request.device?.Team, request.device)
app.auditLog.Device.device.credentials.generated(request.session.User, null, request.device)
reply.send(credentials)
})
app.put('/:deviceId/settings', {
preHandler: app.needsPermission('device:edit-env'), // members only
schema: {
summary: 'Update a devices settings',
tags: ['Devices'],
params: {
type: 'object',
properties: {
deviceId: { type: 'string' }
}
},
body: {
type: 'object',
properties: {
env: { type: 'array', items: { type: 'object', additionalProperties: true } },
autoSnapshot: { type: 'boolean' },
palette: {
type: 'object',
additionalProperties: true
},
editor: {
type: 'object',
additionalProperties: true
},
security: {
type: 'object',
additionalProperties: true
}
}
},
response: {
200: {
$ref: 'APIStatus'
},
'4xx': {
$ref: 'APIError'
}
}
}
}, async (request, reply) => {
const updates = new app.auditLog.formatters.UpdatesCollection()
const currentSettings = await request.device.getAllSettings()
// remove any extra properties from env to ensure they match the format of the body data
// and prevent updates from being logged for unchanged values
currentSettings.env = (currentSettings.env || []).map(e => ({ name: e.name, value: e.value, hidden: e.hidden ?? false }))
if (request.body.env) {
request.body.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 = currentSettings.env.find(currentEnv => currentEnv.name === env.name)
if (existingVar) {
env.value = existingVar.value
}
}
return env
})
}
const captureUpdates = (key) => {
if (key === 'env') {
// transform the env array to a map for better logging format
const currentEnv = currentSettings.env.reduce((acc, e) => {
acc[e.name] = e.value
return acc
}, {})
const newEnv = request.body.env.reduce((acc, e) => {
acc[e.name] = e.value
return acc
}, {})
updates.pushDifferences({ env: currentEnv }, { env: newEnv })
} else {
updates.pushDifferences({ [key]: currentSettings[key] }, { [key]: request.body[key] })
}
}
if (request.teamMembership?.role === Roles.Owner) {
// owner is permitted to update all settings
if (request.body.security?.httpNodeAuth) {
// If type = basic and pass not present, merge in existing value
if (request.body.security.httpNodeAuth.type === 'basic') {
if (!request.body.security.httpNodeAuth.pass) {
request.body.security.httpNodeAuth.pass = currentSettings.security?.httpNodeAuth?.pass
} else {
// Store the hashed password
request.body.security.httpNodeAuth.pass = hash(request.body.security.httpNodeAuth.pass)
}
}
if (request.body.security.httpNodeAuth.type !== 'flowforge-user') {
await app.db.controllers.AuthClient.removeClientForDevice(request.device)
}
}
if (request.body.security?.localAuth) {
// If enabled and pass not present, merge in existing value
if (request.body.security.localAuth.enabled === true) {
if (!request.body.security.localAuth.pass) {
request.body.security.localAuth.pass = currentSettings.security?.localAuth?.pass
} else {
// Store the hashed password
request.body.security.localAuth.pass = hash(request.body.security.localAuth.pass)
}
}
}
await request.device.updateSettings(request.body)
const keys = Object.keys(request.body)
// capture key/val updates sent in body
keys.forEach(key => captureUpdates(key, currentSettings[key], request.body[key]))
} else {
// members are only permitted to update the env and autoSnapshot settings
const settings = {}
if (hasProperty(request.body, 'env')) {
settings.env = request.body.env
captureUpdates('env', currentSettings.env, request.body.env)
}
if (hasProperty(request.body, 'autoSnapshot')) {
settings.autoSnapshot = request.body.autoSnapshot
captureUpdates('autoSnapshot', currentSettings.autoSnapshot, request.body.autoSnapshot)
}
await request.device.updateSettings(settings)
}
await app.db.controllers.Device.sendDeviceUpdateCommand(request.device)
// Log the updates
if (updates.length > 0) {
await app.auditLog.Device.device.settings.updated(request.session.User.id, null, request.device, updates)
}
reply.send({ status: 'okay' })
})
app.get('/:deviceId/settings', {
preHandler: app.needsPermission('device:read'),
schema: {
summary: 'Get a devices settings',
tags: ['Devices'],
params: {
type: 'object',
properties: {
deviceId: { type: 'string' }
}
},
response: {
200: {
type: 'object',
properties: {
env: { type: 'array', items: { type: 'object', additionalProperties: true } },
autoSnapshot: { type: 'boolean' },
palette: { type: 'object', additionalProperties: true },
editor: { type: 'object', additionalProperties: true },
security: { type: 'object', additionalProperties: true }
}
},
'4xx': {
$ref: 'APIError'
}
}
}
}, async (request, reply) => {
const settings = await request.device.getAllSettings()
settings.env = settings.env.map((env) => {
if (Object.hasOwnProperty.call(env, 'hidden') && env.hidden === true) {
env.value = ''
}
return env
})
// Never return the node auth password
if (settings.security?.httpNodeAuth?.pass) {
// Do not return actual value - use a boolean to indicate it is set
settings.security.httpNodeAuth.pass = true
}
// Never return the local login auth password
if (settings.security?.localAuth?.pass) {
// Do not return actual value - use a boolean to indicate it is set
settings.security.localAuth.pass = true
}
if (request.teamMembership?.role === Roles.Owner) {
reply.send(settings)
} else {
reply.send({
env: settings?.env,
autoSnapshot: settings?.autoSnapshot
})
}
})
app.post('/:deviceId/logs', {
preHandler: app.needsPermission('device:read'),
schema: {
summary: 'Start device logging',
tags: ['Devices'],
params: {
type: 'object',
properties: {
deviceId: { type: 'string' }
}
},
response: {
200: {
type: 'object',
properties: {
url: { type: 'string' },
username: { type: 'string' },
password: { type: 'string' }
}
},
'4xx': {
$ref: 'APIError'
}
}
}
}, async (request, reply) => {
const team = await app.db.models.Team.byId(request.device.TeamId)
setTimeout(() => {
app.comms.devices.sendCommand(team.hashid, request.device.hashid, 'startLog', '')
app.log.info(`Enable device logging ${request.device.hashid} in team ${team.hashid}`)
}, 1000)
reply.send(await app.db.controllers.BrokerClient.createClientForFrontend(request.device))
})
/**
* Set device operating mode
* @name /api/v1/devices/:deviceId/mode
* @memberof module:forge/routes/api/device
*/
app.put('/:deviceId/mode', {
preHandler: app.needsPermission('device:editor'),
schema: {
summary: 'Set device mode',
tags: ['Devices'],
params: {
type: 'object',
properties: {
deviceId: { type: 'string' }
}
},
body: {
type: 'object',
properties: {
mode: { type: 'string', nullable: true }
}
},
response: {
200: {
type: 'object',
properties: {
mode: { type: 'string' }
}
},
'4xx': {
$ref: 'APIError'
}
}
}
}, async (request, reply) => {
// setting device mode is only valid for licensed platforms
const isLicensed = app.license.active()
if (isLicensed !== true) {
reply.code(400).send({ code: 'not_licensed', error: 'Device mode can only be set for licensed platforms' })
return
}
const mode = request.body.mode || 'autonomous'
if (mode !== 'autonomous' && mode !== 'developer') {
reply.code(400).send({ code: 'invalid_mode', error: 'Expected device mode option to be either "autonomous" or "developer"' })
return
}
request.device.mode = request.body.mode
await request.device.save()
// send update to device for immediate effect
await app.db.controllers.Device.sendDeviceUpdateCommand(request.device)
// Audit log the change
if (request.device.mode === 'developer') {
await app.auditLog.Team.team.device.developerMode.enabled(request.session.User, null, request.device.Team, request.device)
await app.auditLog.Device.device.developerMode.enabled(request.session.User, null, request.device)
} else {
await app.auditLog.Team.team.device.developerMode.disabled(request.session.User, null, request.device.Team, request.device)
await app.auditLog.Device.device.developerMode.disabled(request.session.User, null, request.device)
}
reply.send({ mode: request.body.mode })
})
/**
* Create a snapshot from a device owned by an instance
* @name /api/v1/devices/:deviceId/snapshot
* @memberof module:forge/routes/api/device
*/
app.post('/:deviceId/snapshot', {
preHandler: app.needsPermission('project:snapshot:create'),
schema: {
summary: 'Create a snapshot from a device owned by an instance',
tags: ['Devices'],
params: {
type: 'object',
properties: {
deviceId: { type: 'string' }
}
},
body: {
type: 'object',
properties: {
name: { type: 'string' },
description: { type: 'string' },
setAsTarget: { type: 'boolean' }
}
},
response: {
200: {
$ref: 'Snapshot'
},
'4xx': {
$ref: 'APIError'
}
}
}
}, async (request, reply) => {
if (request.device.isApplicationOwned) {
reply.code(400).send({ code: 'invalid_device', error: 'Device is not associated with a instance, application owned devices must use the application device snapshot endpoint' })
return
}
if (!request.device.Project) {
reply.code(400).send({ code: 'invalid_device', error: 'Device must be associated with a instance to create a snapshot' })
return
}
const snapshotOptions = {
name: request.body.name,
description: request.body.description,
setAsTarget: request.body.setAsTarget
}
const snapShot = await app.db.controllers.ProjectSnapshot.createSnapshotFromDevice(
request.device.Project,
request.device,
request.session.User,
snapshotOptions
)
snapShot.User = request.session.User
await app.auditLog.Project.project.device.snapshot.created(request.session.User, null, request.device.Project, request.device, snapShot)
if (request.body.setAsTarget) {
await snapShot.reload()
await request.device.Project.updateSetting('deviceSettings', {
targetSnapshot: snapShot.id
})
// Update the targetSnapshot of the devices assigned to this project
await app.db.models.Device.update({ targetSnapshotId: snapShot.id }, {
where: {
ProjectId: request.device.Project.id
}
})
await app.auditLog.Project.project.snapshot.deviceTargetSet(request.session.User, null, request.device.Project, snapShot)
if (app.comms) {
app.comms.devices.sendCommandToProjectDevices(request.device.Team.hashid, request.device.Project.id, 'update', {
snapshot: snapShot.hashid
})
}
}
reply.send(app.db.views.ProjectSnapshot.snapshot(snapShot))
})
/**
* @name /api/v1/devices/:id/audit-log
* @memberof forge.routes.api.devices
*/
app.get('/:deviceId/audit-log', {
preHandler: app.needsPermission('device:audit-log'),
schema: {
summary: '',
tags: ['Devices'],
params: {
type: 'object',
properties: {
deviceId: { type: 'string' }
}
},
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.forDevice(request.device.id, paginationOptions)
const result = app.db.views.AuditLog.auditLog(logEntries)
reply.send(result)
})
/**
* @name /api/v1/devices/:id/audit-log/export
* @memberof forge.routes.api.devices
*/
app.get('/:deviceId/audit-log/export', {
preHandler: app.needsPermission('device:audit-log'),
schema: {
summary: '',
tags: ['Devices'],
params: {
type: 'object',
properties: {
deviceId: { type: 'string' }
}
},
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.forDevice(request.device.id, 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'))
})
async function assignDeviceToProject (device, project) {
await device.setProject(project)
// Set the target snapshot to match the project's one
const deviceSettings = await project.getSetting('deviceSettings')
device.targetSnapshotId = deviceSettings?.targetSnapshot
device.DeviceGroupId = null // not relevant to instances at this time, but no harm in clearing it
return true
}
async function assignDeviceToApplication (device, application) {
await device.setApplication(application)
// Since the application is being changed, clear the deviceGroup
device.DeviceGroupId = null
return true
}
// #endregion
}