@flowfuse/flowfuse
Version:
An open source low-code development platform
1,156 lines (1,076 loc) • 52.6 kB
JavaScript
const { KEY_SETTINGS, KEY_HEALTH_CHECK_INTERVAL, KEY_DISABLE_AUTO_SAFE_MODE, KEY_SHARED_ASSETS } = require('../../db/models/ProjectSettings')
const { Roles } = require('../../lib/roles')
const ProjectActions = require('./projectActions')
const ProjectDevices = require('./projectDevices')
const ProjectSnapshots = require('./projectSnapshots')
const projectShared = require('./shared/project.js')
/**
* Instance api routes
*
* Of note: Instances were previously called projects
*
* - /api/v1/projects
*
* - Any route that has a :instanceId parameter will:
* - Ensure the session user is either admin or has a role on the corresponding team
* - request.project prepopulated with the team object
* - request.teamMembership prepopulated with the user role ({role: XYZ})
* (unless they are admin)
*
* @namespace project
* @memberof forge.routes.api
*/
module.exports = async function (app) {
app.addHook('preHandler', projectShared.defaultPreHandler.bind(null, app))
app.register(ProjectDevices, { prefix: '/:instanceId/devices' })
app.register(ProjectActions, { prefix: '/:instanceId/actions' })
app.register(ProjectSnapshots, { prefix: '/:instanceId/snapshots' })
/**
* Get the details of a given instance
* @name /api/v1/projects/:instanceId
* @static
* @memberof forge.routes.api.project
*/
app.get('/:instanceId', {
preHandler: app.needsPermission('project:read'),
schema: {
summary: 'Get details of an instance',
tags: ['Instances'],
params: {
type: 'object',
properties: {
instanceId: { type: 'string' }
}
},
response: {
200: {
allOf: [
{ $ref: 'Instance' },
{ $ref: 'InstanceStatus' }
]
},
'4xx': {
$ref: 'APIError'
}
}
}
}, async (request, reply) => {
// Storage flow needed for live status
const projectPromise = app.db.views.Project.project(request.project)
const projectStatePromise = request.project.liveState()
const project = await projectPromise
const projectState = await projectStatePromise
const teamType = await request.project.Team.getTeamType()
const customCatalogsEnabledForTeam = app.config.features.enabled('customCatalogs') && teamType.getFeatureProperty('customCatalogs', false)
if (!customCatalogsEnabledForTeam) {
delete project.settings?.palette?.npmrc
delete project.settings?.palette?.catalogue
} else {
if ((!request.teamMembership && request.session.User.admin) || request.teamMembership.role < Roles.Owner || request.project.ProjectTemplate.policy.palette.npmrc === false) {
if (project.settings?.palette?.npmrc !== undefined) {
let temp = project.settings.palette.npmrc
temp = temp.replace(/_authToken="?(.*)"?/g, '_authToken="xxxxxxx"')
temp = temp.replace(/_auth="?(.*)"?/g, '_auth="xxxxxxx"')
temp = temp.replace(/_password="?(.*)"?/, '_password="xxxxxxx"')
project.settings.palette.npmrc = temp
}
if (project.template.settings?.palette?.npmrc !== undefined) {
let temp = project.template.settings.palette.npmrc
temp = temp.replace(/_authToken="?(.*)"?/g, '_authToken="xxxxxxx"')
temp = temp.replace(/_auth="?(.*)"?/g, '_auth="xxxxxxx"')
temp = temp.replace(/_password="?(.*)"?/, '_password="xxxxxxx"')
project.template.settings.palette.npmrc = temp
}
}
}
if (project.template?.settings?.env) {
project.template.settings.env = project.template.settings.env.map(env => {
if (env.hidden) {
env.value = ''
}
return env
})
}
reply.send({ ...project, ...projectState })
})
/**
* Create a project
* @name /api/v1/projects
* @memberof forge.routes.api.project
*/
app.post('/', {
preHandler: [
async (request, reply) => {
request.application = await app.db.models.Application.byId(request.body.applicationId)
if (!request.application) {
return reply.code(404).send({ code: 'not_found', error: 'application not found' })
}
request.teamMembership = await request.session.User.getTeamMembership(request.application.Team.id)
if (!request.teamMembership && !request.session.User.admin) {
return reply.code(401).send({ code: 'unauthorized', error: 'unauthorized' })
}
},
app.needsPermission('project:create')
],
schema: {
summary: 'Create an instance',
tags: ['Instances'],
body: {
type: 'object',
required: ['name', 'projectType', 'stack', 'template', 'applicationId'],
properties: {
name: { type: 'string' },
applicationId: { type: 'string' },
projectType: { type: 'string' },
stack: { type: 'string' },
flowBlueprintId: { type: 'string' },
template: { type: 'string' },
sourceProject: {
type: 'object',
properties: {
id: { type: 'string' },
options: { type: 'object' }
}
}
}
},
response: {
200: {
allOf: [
{ $ref: 'Instance' },
{ $ref: 'InstanceStatus' }
]
},
'4xx': {
$ref: 'APIError'
}
}
}
}, async (request, reply) => {
const team = await request.application.getTeam()
const application = request.application
const projectType = await app.db.models.ProjectType.byId(request.body.projectType)
const projectStack = await app.db.models.ProjectStack.byId(request.body.stack)
const projectTemplate = await app.db.models.ProjectTemplate.byId(request.body.template)
let flowBlueprint
if (request.body.flowBlueprintId) {
flowBlueprint = await app.db.models.FlowTemplate.byId(request.body.flowBlueprintId)
if (!flowBlueprint) {
reply.code(400).send({ code: 'invalid_flow_blueprint', error: 'Flow Blueprint not found' })
return
}
// ensure this teams type is allowed to use this blueprint
const teamType = await team.getTeamType()
if (flowBlueprint.teamTypeScope && !flowBlueprint.teamTypeScope.includes(teamType.id)) {
reply.code(400).send({ code: 'invalid_flow_blueprint', error: 'Flow Blueprint not allowed for this team' })
return
}
}
// Read in any source to copy from
let sourceProject
if (request.body.sourceProject?.id) {
if (flowBlueprint) {
reply.code(400).send({ code: 'invalid_request', error: 'Cannot use both sourceProject and flowBlueprintId' })
return
}
sourceProject = await app.db.models.Project.byId(request.body.sourceProject.id)
if (!sourceProject) {
reply.code(400).send({ code: 'invalid_source_project', error: 'Source Project Not Found' })
return
}
}
// Create the real project (performs validation)
let project
try {
project = await app.db.controllers.Project.create(
team,
application,
request.session.User,
projectType,
projectStack,
projectTemplate,
{
name: request.body.name,
ha: request.body.ha,
sourceProject,
sourceProjectOptions: request.body.sourceProject?.options,
flowBlueprint
}
)
} catch (err) {
return reply
.code(err.statusCode || 400)
.send({
code: err.code || 'unexpected_error',
error: err.error || err.message
})
}
const projectViewPromise = app.db.views.Project.project(project)
const projectStatePromise = project.liveState()
reply.send({ ...await projectViewPromise, ...await projectStatePromise })
})
/**
* Delete a project
* @name /api/v1/projects/:id
* @memberof forge.routes.api.project
*/
app.delete('/:instanceId', {
preHandler: app.needsPermission('project:delete'),
schema: {
summary: 'Delete an instance',
tags: ['Instances'],
params: {
type: 'object',
properties: {
instanceId: { type: 'string' }
}
},
response: {
200: {
$ref: 'APIStatus'
},
'4xx': {
$ref: 'APIError'
},
500: {
$ref: 'APIError'
}
}
}
}, async (request, reply) => {
try {
try {
await app.containers.remove(request.project)
} catch (err) {
// Swallow no such container error code (as it may have been removed from wrapper already)
// https://github.com/apocas/dockerode/blob/edf29ccb2c2c7bfcdd1cf3cacbe861bd0f4bc87a/lib/network.js#L67
if (err?.statusCode !== 404) {
throw err
}
}
if (app.comms) {
app.comms.devices.sendCommandToProjectDevices(request.project.Team.hashid, request.project.id, 'update', {
project: null
})
}
await request.project.destroy()
await app.auditLog.Team.project.deleted(request.session.User, null, request.project.Team, request.project)
await app.auditLog.Project.project.deleted(request.session.User, null, request.project.Team, request.project)
reply.send({ status: 'okay' })
} catch (err) {
reply.code(500).send({ code: 'unexpected_error', error: err.toString() })
}
})
/**
* Update a project
* @name /api/v1/projects/:id
* @memberof forge.routes.api.project
*/
app.put('/:instanceId', {
preHandler: async (request, reply) => {
// First, check what is being set & check permissions accordingly.
// * If the only value sent is `request.body.settings.env`, then we only need 'project:edit-env' permission
// * Otherwise, everything else requires 'project:edit' permission
const bodyKeys = Object.keys(request.body || {})
const settingsKeys = Object.keys(request.body?.settings || {})
if (bodyKeys.length === 1 && bodyKeys[0] === 'settings' && settingsKeys.length === 1 && settingsKeys[0] === 'env') {
return app.needsPermission('project:edit-env')(request, reply)
} else {
return app.needsPermission('project:edit')(request, reply).then(res => {
request.allSettingsEdit = true
return res
})
}
},
schema: {
summary: 'Update an instance',
tags: ['Instances'],
params: {
type: 'object',
properties: {
instanceId: { type: 'string' }
}
},
body: {
type: 'object',
properties: {
name: { type: 'string' },
hostname: { type: 'string' },
settings: { type: 'object' },
launcherSettings: { type: 'object' },
projectType: { type: 'string' },
stack: { type: 'string' },
sourceProject: {
type: 'object',
properties: {
id: { type: 'string' },
options: { type: 'object' }
}
}
}
},
response: {
200: {
allOf: [
{ $ref: 'Instance' },
{ $ref: 'InstanceStatus' }
]
},
'4xx': {
$ref: 'APIError'
},
500: {
$ref: 'APIError'
}
}
}
}, async (request, reply) => {
// Export this one project over another
if (request.body.sourceProject) {
const sourceProject = await app.db.models.Project.byId(request.body.sourceProject.id)
const targetProject = request.project
const options = request.body.sourceProject.options
if (!sourceProject) {
reply.code(404).send('Source Project not found')
}
if (sourceProject.Team.id !== request.project.Team.id) {
reply.code(403).send('Source Project and Target not in same team')
}
app.db.controllers.Project.setInflightState(request.project, 'importing')
app.db.controllers.Project.setInDeploy(request.project)
await app.auditLog.Project.project.copied(request.session.User.id, null, sourceProject, request.project)
await app.auditLog.Project.project.imported(request.session.User.id, null, request.project, sourceProject)
// Early return, status is loaded async
reply.code(200).send({})
exportProjectToExistingProject(sourceProject, targetProject, options) // runs async
return
}
/// Validation of changes
const changesToPersist = {}
// Name
const reqName = request.body.name?.trim()
const reqSafeName = reqName?.toLowerCase()
const projectName = request.project.name?.trim()
if (reqName && projectName !== reqName) {
if (app.db.models.Project.BANNED_NAME_LIST.includes(reqSafeName)) {
reply.status(409).type('application/json').send({ code: 'invalid_project_name', error: 'name not allowed' })
return
}
if (await app.db.models.Project.isNameUsed(reqSafeName)) {
reply.status(409).type('application/json').send({ code: 'invalid_project_name', error: 'name in use' })
return
}
changesToPersist.name = { from: projectName, to: reqName }
}
// Settings
if (request.body.settings) {
let bodySettings
if (request.allSettingsEdit) {
// store all body settings if user is owner
bodySettings = request.body.settings
} else {
// only store settings.env if user is member
bodySettings = {
env: request.body.settings.env
}
}
let newSettings
try {
newSettings = app.db.controllers.ProjectTemplate.validateSettings(bodySettings, request.project.ProjectTemplate)
} catch (err) {
reply.code(400).send({ code: 'settings_validation', error: `${err.message}` })
return
}
if (newSettings.httpNodeAuth?.type === 'flowforge-user') {
const teamType = await request.project.Team.getTeamType()
if (teamType.properties.features?.teamHttpSecurity === false) {
reply.code(400).send({ code: 'invalid_request', error: 'FlowFuse User Authentication not available for this team type' })
return
}
}
// Merge the settings into the existing values
const currentProjectSettings = await request.project.getSetting(KEY_SETTINGS) || {}
if (newSettings.env && Array.isArray(newSettings.env)) {
newSettings.env = newSettings.env.map(env => {
// hidden env vars are received as empty strings so we'll replace the empty string with the previous value,
// allowing them to be overwritten when needed
if (Object.prototype.hasOwnProperty.call(env, 'hidden') && env.hidden && !env.value.length) {
const previousValue = currentProjectSettings.env.find(e => e.name === env.name)
env.value = previousValue.value
}
return env
})
}
const updatedSettings = app.db.controllers.ProjectTemplate.mergeSettings(currentProjectSettings, newSettings)
changesToPersist.settings = { from: currentProjectSettings, to: updatedSettings }
}
// Project Type
if (request.body.projectType) {
const newProjectType = await app.db.models.ProjectType.byId(request.body.projectType)
if (!newProjectType) {
reply.code(400).send({ code: 'invalid_project_type', error: 'Invalid project type' })
return
}
// Setting of project type for first time only (legacy)
const legacyFirstUpdate = !request.project.ProjectType
if (legacyFirstUpdate) {
const existingStackProjectType = request.project.ProjectStack.ProjectTypeId
if (existingStackProjectType && newProjectType.id !== existingStackProjectType) {
reply.code(400).send({ code: 'invalid_request', error: 'Mismatch between stack project type and new project type' })
return
}
} else {
// Must specify stack if changing project
const newStack = request.body.stack
if (!newStack) {
reply.code(400).send({ code: 'invalid_request', error: 'Stack must be set when changing project type' })
return
}
}
changesToPersist.projectType = { from: request.project.projectType, to: newProjectType, firstUpdate: legacyFirstUpdate }
}
// Project Stack
if (request.body.stack) {
const stack = await app.db.models.ProjectStack.byId(request.body.stack)
if (!stack) {
reply.code(400).send({ code: 'invalid_stack', error: 'Invalid stack' })
return
}
changesToPersist.stack = { from: request.project.stack, to: stack }
}
// Launcher settings
if (request.body?.launcherSettings) {
if (request.body.launcherSettings.healthCheckInterval) {
const oldInterval = await request.project.getSetting(KEY_HEALTH_CHECK_INTERVAL)
const newInterval = parseInt(request.body.launcherSettings.healthCheckInterval, 10)
if (isNaN(newInterval) || newInterval < 5000) {
reply.code(400).send({ code: 'invalid_heathCheckInterval', error: 'Invalid heath check interval' })
return
}
if (oldInterval !== newInterval) {
changesToPersist.healthCheckInterval = { from: oldInterval, to: newInterval }
}
}
if (typeof request.body.launcherSettings.disableAutoSafeMode === 'boolean') {
const oldInterval = await request.project.getSetting(KEY_DISABLE_AUTO_SAFE_MODE)
const newInterval = request.body.launcherSettings.disableAutoSafeMode
if (oldInterval !== newInterval) {
changesToPersist.disableAutoSafeMode = { from: oldInterval, to: newInterval }
}
}
}
/// Persist the changes
const updates = new app.auditLog.formatters.UpdatesCollection()
const transaction = await app.db.sequelize.transaction() // start a transaction
const changesToProjectDefinition = (changesToPersist.stack || changesToPersist.projectType || changesToPersist.name) && !changesToPersist.projectType?.firstUpdate
let repliedEarly = false
try {
let resumeProject, targetState
if (changesToProjectDefinition) {
// Early return and complete the rest async
app.db.controllers.Project.setInflightState(request.project, 'starting') // TODO: better inflight state needed
reply.code(200).send({})
repliedEarly = true
const suspendOptions = {
skipBilling: changesToPersist.stack && !changesToPersist.projectType
}
const result = await suspendProject(request.project, suspendOptions)
resumeProject = result.resumeProject
targetState = result.targetState
}
if (changesToPersist.name) {
request.project.name = changesToPersist.name.to
await request.project.save({ transaction })
updates.push('name', changesToPersist.name.from, changesToPersist.name.to)
}
if (changesToPersist.settings) {
await request.project.updateSetting(KEY_SETTINGS, changesToPersist.settings.to, { transaction })
if (request.allSettingsEdit) {
updates.pushDifferences(changesToPersist.settings.from, changesToPersist.settings.to)
} else {
updates.pushDifferences({ env: changesToPersist.settings.from.env }, { env: changesToPersist.settings.to.env })
}
}
if (changesToPersist.stack || changesToPersist.projectType) {
if (changesToPersist.projectType && changesToPersist.stack) {
app.log.info(`Updating project ${request.project.id} to type: '${changesToPersist.projectType.to.hashid}', stack: '${changesToPersist.stack.to.hashid}'`)
} else if (changesToPersist.projectType) {
app.log.info(`Updating project ${request.project.id} to use type ${changesToPersist.projectType.to.hashid} for the first time (legacy)`)
} else {
app.log.info(`Updating project ${request.project.id} to use stack ${changesToPersist.stack.to.hashid}`)
}
if (changesToPersist.projectType?.to) {
await request.project.setProjectType(changesToPersist.projectType.to, { transaction })
}
if (changesToPersist.stack?.to) {
await request.project.setProjectStack(changesToPersist.stack.to, { transaction })
}
}
if (changesToPersist.healthCheckInterval) {
await request.project.updateSetting(KEY_HEALTH_CHECK_INTERVAL, changesToPersist.healthCheckInterval.to, { transaction })
updates.pushDifferences({ healthCheckInterval: changesToPersist.healthCheckInterval.from }, { healthCheckInterval: changesToPersist.healthCheckInterval.to })
}
if (changesToPersist.disableAutoSafeMode) {
await request.project.updateSetting(KEY_DISABLE_AUTO_SAFE_MODE, changesToPersist.disableAutoSafeMode.to, { transaction })
updates.pushDifferences({ disableAutoSafeMode: changesToPersist.disableAutoSafeMode.from }, { disableAutoSafeMode: changesToPersist.disableAutoSafeMode.to })
}
await transaction.commit() // all good, commit the transaction
// Log the updates
if (updates.length > 0) {
await app.auditLog.Project.project.settings.updated(request.session.User.id, null, request.project, updates)
}
if (changesToPersist.projectType) {
await app.auditLog.Project.project.type.changed(request.session.User, null, request.project, changesToPersist.projectType.to)
}
if (changesToPersist.stack) {
await app.auditLog.Project.project.stack.changed(request.session.User, null, request.project, changesToPersist.stack.to)
}
// Awaken the project
if (changesToProjectDefinition) {
await unSuspendProject(resumeProject, targetState)
}
} catch (error) {
app.log.error('Error while updating project:')
app.log.error(error)
await transaction.rollback() // rollback the transaction.
if (!repliedEarly) {
reply.code(500).send({ code: 'unexpected_error', error: error.message })
}
return
}
if (repliedEarly) {
// No further response needed
return
}
// Bust sequelize caching on project settings
if (changesToPersist.hostname || changesToPersist.settings) {
await request.project.reload({
include: [
{ model: app.db.models.ProjectSettings }
]
})
}
// Result
const project = await app.db.models.Project.byId(request.project.id) // Reload project entirely
const projectView = await app.db.views.Project.project(request.project)
let result
if (request.teamMembership.role >= Roles.Owner) {
result = projectView
} else {
// exclude template object in response when not owner
result = {
createdAt: projectView.createdAt,
id: projectView.id,
name: projectView.name,
links: projectView.links,
projectType: projectView.projectType,
stack: projectView.stack,
team: projectView.team,
updatedAt: projectView.updatedAt,
url: projectView.url,
settings: {
env: projectView.settings?.env
}
}
}
result.meta = await app.containers.details(project) || { state: 'unknown' }
result.team = await app.db.views.Team.teamSummary(project.Team)
reply.send(result)
async function unSuspendProject (resumeProject, targetState) {
if (resumeProject) {
app.log.info(`Restarting project ${request.project.id}`)
request.project.state = targetState
await request.project.save()
// Ensure the project has the full stack object
await request.project.reload()
const startResult = await app.containers.start(request.project)
startResult.started.then(async () => {
await app.auditLog.Project.project.started(request.session.User, null, request.project)
app.db.controllers.Project.clearInflightState(request.project)
return true
}).catch(err => {
app.log.info(`Failed to restart project ${request.project.id}`)
throw err
})
} else {
app.db.controllers.Project.clearInflightState(request.project)
}
}
async function suspendProject (project = request.project, options) {
let resumeProject = false
const targetState = project.state
if (project.state !== 'suspended') {
resumeProject = true
app.log.info(`Stopping project ${project.id}`)
await app.containers.stop(project, options)
await app.auditLog.Project.project.suspended(request.session.User, null, project)
}
return { resumeProject, targetState }
}
async function exportProjectToExistingProject (sourceProject, targetProject, options) {
const { resumeProject, targetState } = await suspendProject(targetProject)
// Nodes
const sourceSettingsString = ((await app.db.models.StorageSettings.byProject(sourceProject.id))?.settings) ?? '{}'
const sourceSettings = JSON.parse(sourceSettingsString)
let targetStorageSettings = await app.db.models.StorageSettings.byProject(targetProject.id)
const targetSettingString = targetStorageSettings?.settings || '{}'
const targetSettings = JSON.parse(targetSettingString)
targetSettings.nodes = sourceSettings.nodes
if (targetStorageSettings) {
targetStorageSettings.settings = JSON.stringify(targetSettings)
} else {
targetStorageSettings = await app.db.models.StorageSettings.create({
settings: JSON.stringify(targetSettings),
ProjectId: targetProject.id
})
}
await targetStorageSettings.save()
// Flows
if (options.flows) {
let sourceFlow = await app.db.models.StorageFlow.byProject(sourceProject.id)
let targetFlow = await app.db.models.StorageFlow.byProject(targetProject.id)
if (!sourceFlow) {
sourceFlow = {
flow: '[]'
}
}
if (targetFlow) {
targetFlow.flow = sourceFlow.flow
} else {
targetFlow = await app.db.models.StorageFlow.create({
flow: sourceFlow.flow,
ProjectId: targetProject.id
})
}
await targetFlow.save()
}
// Credentials
if (options.credentials) {
/*
To copy over the credentials, we have to:
- get the source credentials + credentialSecret
- get the target credentials + credentialSecret
- decrypt credentials from src and re-encrypt the
- credentials using the target key for target StorageCredentials
*/
const origCredentials = await app.db.models.StorageCredentials.byProject(sourceProject.id)
if (origCredentials) {
let trgCredentialSecret = await targetProject.getSetting('credentialSecret')
if (trgCredentialSecret == null) {
trgCredentialSecret = targetSettings?._credentialSecret || app.db.models.Project.generateCredentialSecret()
targetProject.updateSetting('credentialSecret', trgCredentialSecret)
delete targetSettings._credentialSecret
}
const srcCredentials = JSON.parse(origCredentials.credentials)
const srcCredentialSecret = await sourceProject.getSetting('credentialSecret') || sourceSettings._credentialSecret
let targetCreds = await app.db.models.StorageCredentials.byProject(targetProject.id)
if (targetCreds && srcCredentials) {
targetCreds.credentials = JSON.stringify(app.db.controllers.Project.exportCredentials(srcCredentials, srcCredentialSecret, trgCredentialSecret))
await targetCreds.save()
} else if (srcCredentials) {
targetCreds = await app.db.models.StorageCredentials.create({
credentials: JSON.stringify(app.db.controllers.Project.exportCredentials(srcCredentials, srcCredentialSecret, trgCredentialSecret)),
ProjectId: targetProject.id
})
await targetCreds.save()
}
}
}
// Template
if (options.template) {
targetProject.ProjectTemplateId = sourceProject.ProjectTemplateId
await targetProject.save()
await targetProject.reload()
}
// Settings
let updateSettings = false
const sourceProjectSettings = await sourceProject.getSetting(KEY_SETTINGS) || { env: [] }
let targetProjectSettings = await targetProject.getSetting(KEY_SETTINGS) || { env: [] }
const targetProjectEnvVars = targetProjectSettings.env
if (options.settings) {
targetProjectSettings = sourceProjectSettings
if (!options.envVars) {
// Need to keep the existing env vars
targetProjectSettings.env = targetProjectEnvVars
}
updateSettings = true
}
if (options.envVars) {
targetProjectSettings.env = mergeEnvVars(options, sourceProjectSettings.env, targetProjectEnvVars)
updateSettings = true
}
if (updateSettings) {
await targetProject.updateSetting(KEY_SETTINGS, targetProjectSettings)
}
await unSuspendProject(resumeProject, targetState)
}
})
/**
* Provide Project specific settings.js
*
* @name /api/v1/projects/:id/settings
* @memberof forge.routes.api.project
*/
app.get('/:instanceId/settings', {
preHandler: (request, reply, done) => {
// check accessToken is project scope
// (ownerId already checked at top-level preHandler)
if (request.session.ownerType !== 'project') {
reply.code(401).send({ code: 'unauthorized', error: 'unauthorized' })
} else {
done()
}
},
schema: {
summary: 'Get an instance runtime settings (instance tokens only)',
tags: ['Instances'],
params: {
type: 'object',
properties: {
instanceId: { type: 'string' }
}
},
response: {
200: {
type: 'object',
additionalProperties: true
},
'4xx': {
$ref: 'APIError'
}
}
}
}, async (request, reply) => {
if (request.project.state === 'suspended') {
app.log.warn(`Instance ${request.project} attempted to get settings whilst suspended`)
reply.code(400).send({ code: 'project_suspended', error: 'Instance suspended' })
return
}
// get settings from the driver
const settings = await app.containers.settings(request.project)
// add instance settings
settings.env = settings.env || {}
settings.baseURL = request.project.url
settings.forgeURL = app.config.base_url
settings.fileStore = app.config.fileStore ? { ...app.config.fileStore } : null
settings.assistant = {
enabled: app.config.assistant?.enabled || false,
requestTimeout: app.config.assistant?.requestTimeout
}
settings.teamID = request.project.Team.hashid
settings.storageURL = request.project.storageURL
settings.auditURL = request.project.auditURL
settings.state = request.project.state
settings.stack = request.project.ProjectStack?.properties || {}
settings.healthCheckInterval = await request.project.getSetting(KEY_HEALTH_CHECK_INTERVAL)
settings.disableAutoSafeMode = await request.project.getSetting(KEY_DISABLE_AUTO_SAFE_MODE)
settings.settings = await app.db.controllers.Project.getRuntimeSettings(request.project)
if (settings.settings.env) {
settings.env = Object.assign({}, settings.settings.env, settings.env)
delete settings.settings.env
}
const teamType = await request.project.Team.getTeamType()
if (app.config.features.enabled('ha') && teamType.getFeatureProperty('ha', true)) {
const ha = await request.project.getHASettings()
if (ha && ha.replicas > 1) {
settings.ha = ha
}
}
const customCatalogsEnabledForTeam = app.config.features.enabled('customCatalogs') && teamType.getFeatureProperty('customCatalogs', false)
if (!customCatalogsEnabledForTeam) {
delete settings.settings?.palette?.npmrc
delete settings.settings?.palette?.catalogue
}
const teamNPMEnabled = app.config.features.enabled('npm') && teamType.getFeatureProperty('npm', false)
if (teamNPMEnabled) {
const npmRegURL = new URL(app.config.npmRegistry.url)
const deviceNPMPassword = await app.db.controllers.AccessToken.createTokenForNPM(request.project, request.project.Team)
const token = Buffer.from(`p-${request.project.id}@${settings.teamID}:${deviceNPMPassword.token}`).toString('base64')
if (settings.settings?.palette?.npmrc) {
settings.settings.palette.npmrc = `${settings.settings.palette.npmrc}\n` +
`@flowfuse-${settings.teamID}:registry=${app.config.npmRegistry.url}\n` +
`//${npmRegURL.host}:_auth="${token}"\n`
} else {
settings.settings.palette.npmrc =
`@flowfuse-${settings.teamID}:registry=${app.config.npmRegistry.url}\n` +
`//${npmRegURL.host}:_auth="${token}"\n`
}
if (settings.settings?.palette?.catalogue) {
settings.settings.palette.catalogue
.push(`${app.config.base_url}/api/v1/teams/${settings.teamID}/npm/catalogue?instance=${request.project.id}`)
} else {
settings.settings.palette.catalogue = [
`${app.config.base_url}/api/v1/teams/${settings.teamID}/npm/catalogue?instance=${request.project.id}`
]
}
}
if (app.config.features.enabled('staticAssets') && teamType.getFeatureProperty('staticAssets', false)) {
const sharingConfig = await request.project.getSetting(KEY_SHARED_ASSETS) || {}
// Stored as object with path->config. Need to transform to an array of settings
const sharingPaths = Object.keys(sharingConfig)
if (sharingPaths.length > 0) {
settings.settings.httpStatic = []
sharingPaths.forEach(filePath => {
settings.settings.httpStatic.push({
path: filePath,
...sharingConfig[filePath]
})
})
}
settings.httpStatic = sharingConfig
}
settings.features = {
'shared-library': app.config.features.enabled('shared-library') && teamType.getFeatureProperty('shared-library', true),
projectComms: app.config.features.enabled('projectComms') && teamType.getFeatureProperty('projectComms', true),
teamBroker: app.config.features.enabled('teamBroker') && teamType.getFeatureProperty('teamBroker', true)
}
reply.send(settings)
})
/**
* Get project logs
* - returns most recent 30 entries
* - ?cursor= can be used to set the 'most recent log entry' to query from
* - ?limit= can be used to modify how many entries to return
* @name /api/v1/projects/:id/logs
* @memberof forge.routes.api.project
*/
app.get('/:instanceId/logs', {
preHandler: app.needsPermission('project:log'),
schema: {
summary: 'Get instance logs',
tags: ['Instances'],
params: {
type: 'object',
properties: {
instanceId: { type: 'string' }
}
},
query: {
$ref: 'PaginationParams'
},
response: {
200: {
type: 'object',
properties: {
meta: {
allOf: [
{ $ref: 'PaginationMeta' },
{
type: 'object',
properties: {
first_entry: { type: 'string' },
last_entry: { type: 'string' }
}
}
]
},
log: { type: 'array', items: { type: 'object', additionalProperties: true } }
}
},
'4xx': {
$ref: 'APIError'
},
500: {
$ref: 'APIError'
}
}
}
}, async (request, reply) => {
if (request.project.state === 'suspended') {
reply.code(400).send({ code: 'project_suspended', error: 'Project suspended' })
return
}
try {
const paginationOptions = app.getPaginationOptions(request, { limit: 30 })
let logs = await app.containers?.logs(request.project)
const firstLogCursor = logs.length > 0 ? logs[0].ts : null
const fullLogLength = logs.length
const lastLogCursor = logs.length > 1 ? logs[fullLogLength - 1].ts : null
if (!paginationOptions.cursor) {
logs = logs.slice(-paginationOptions.limit)
} else {
let cursor = paginationOptions.cursor
let cursorDirection = true // 'next'
if (cursor[0] === '-') {
cursorDirection = false
cursor = cursor.substring(1)
}
let i = 0
for (;i < fullLogLength; i++) {
if (logs[i].ts === cursor) {
break
} else if (logs[i].ts > cursor) {
if (i) { i-- }
break
}
}
if (i === fullLogLength) {
// cursor not found
logs = []
} else if (cursorDirection) {
// logs *after* cursor
logs = logs.slice(i + 1, i + 1 + paginationOptions.limit)
} else {
// logs *before* cursor
logs = logs.slice(Math.max(0, i - 1 - paginationOptions.limit), i)
}
}
const result = {
meta: {
// next_cursor - are there more recent logs to get?
next_cursor: logs.length > 0 ? logs[logs.length - 1].ts : undefined,
previous_cursor: logs.length > 0 && logs[0].ts !== firstLogCursor ? ('-' + logs[0].ts) : undefined,
first_entry: firstLogCursor,
last_entry: lastLogCursor
},
log: logs
}
reply.send(result)
} catch (error) {
let responseCode = error.response?.status || 500 // default to 500
error.errorCode = 'unknown_error'
error.errorMessage = error.message || 'Unknown error'
if (error.code === 'ECONNREFUSED') {
responseCode = 503 // service unavailable
error.errorCode = 'connection_refused'
error.errorMessage = 'Connection refused'
} else if (error.code === 'ECONNRESET') {
responseCode = 503 // service unavailable
error.errorCode = 'connection_reset'
error.errorMessage = 'Connection reset'
} else if (error.code === 'ENOTFOUND') {
responseCode = 503 // service unavailable
error.errorCode = 'not_found'
error.errorMessage = 'Not found'
}
const info = `Instance: ${request.project.id} (${request.project.name}), Error: ${error.errorMessage} (${responseCode})`
app.log.warn(`Unable to get Node-RED instance logs from its Launcher. ${info}`)
reply.code(responseCode).send({ code: error.errorCode, error: error.errorMessage })
}
})
/**
* TODO: Add support for filtering by instance param when this is migrated to application API
* @name /api/v1/projects/:id/audit-log
* @memberof forge.routes.api.project
*/
app.get('/:instanceId/audit-log', {
preHandler: app.needsPermission('project:audit-log'),
schema: {
summary: 'Get instance audit event entries',
tags: ['Instances'],
params: {
type: 'object',
properties: {
instanceId: { type: 'string' }
}
},
query: {
allOf: [
{ $ref: 'PaginationParams' },
{ $ref: 'AuditLogQueryParams' }
]
},
response: {
200: {
type: 'object',
properties: {
meta: { $ref: 'PaginationMeta' },
count: { type: 'number' },
log: { $ref: 'AuditLogEntryList' },
associations: {
type: 'object',
properties: {
applications: {
type: 'array',
items: { $ref: 'ApplicationSummary' }
},
instances: { $ref: 'InstanceSummaryList' },
devices: { $ref: 'DeviceSummaryList' }
}
}
}
},
'4xx': {
$ref: 'APIError'
}
}
}
}, async (request, reply) => {
const paginationOptions = app.getPaginationOptions(request)
const logEntries = await app.db.models.AuditLog.forProject(request.project.id, paginationOptions)
const result = app.db.views.AuditLog.auditLog(logEntries)
reply.send(result)
})
/**
* TODO: Add support for filtering by instance param when this is migrated to application API
* Export logs as CSV
* @name /api/v1/projects/:id/audit-log/export
* @memberof forge.routes.api.project
*/
app.get('/:instanceId/audit-log/export', {
preHandler: app.needsPermission('project:audit-log'),
schema: {
summary: 'Get instance audit event entries',
tags: ['Instances'],
params: {
type: 'object',
properties: {
instanceId: { 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.forProject(request.project.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'))
})
/**
*
* @name /api/v1/projects/:id/import
* @memberof forge.routes.api.project
*/
app.post('/:instanceId/import', {
preHandler: app.needsPermission('project:edit'),
schema: {
summary: 'Import flows to the instance',
tags: ['Instances'],
params: {
type: 'object',
properties: {
instanceId: { type: 'string' }
}
},
body