UNPKG

@flowfuse/flowfuse

Version:

An open source low-code development platform

385 lines (373 loc) • 15.7 kB
/** * Snapshot api routes * * - /api/v1/snapshots/ * * @namespace project * @memberof forge.routes.api */ const { UpdatesCollection } = require('../../auditLog/formatters.js') module.exports = async function (app) { /** @type {typeof import('../../db/controllers/Snapshot.js')} */ const snapshotController = app.db.controllers.Snapshot /** @type {typeof import('../../db/views/ProjectSnapshot.js')} */ const projectSnapshotView = app.db.views.ProjectSnapshot const applicationLogger = require('../../../forge/auditLog/application').getLoggers(app) const projectLogger = require('../../../forge/auditLog/project').getLoggers(app) app.addHook('preHandler', async (request, reply) => { try { request.ownerType = null request.owner = null if (request.params.id) { // non upload route request.snapshot = await app.db.models.ProjectSnapshot.byId(request.params.id) if (!request.snapshot) { return reply.code(404).send({ code: 'not_found', error: 'Not Found' }) } request.ownerType = request.snapshot.ownerType if (request.ownerType === 'instance') { request.owner = await request.snapshot.getProject() if (!request.owner) { return reply.code(404).send({ code: 'not_found', error: 'Not Found' }) } } else if (request.ownerType === 'device') { request.owner = await request.snapshot.getDevice() if (!request.owner) { return reply.code(404).send({ code: 'not_found', error: 'Not Found' }) } } else { return reply.code(404).send({ code: 'not_found', error: 'Not Found' }) } } else if (request.body.ownerId && request.body.ownerType && request.body.snapshot) { // upload route if (request.body.ownerType === 'device') { request.owner = await app.db.models.Device.byId(request.body.ownerId) request.ownerType = 'device' } else if (request.body.ownerType === 'instance') { request.owner = await app.db.models.Project.byId(request.body.ownerId) request.ownerType = 'instance' } else { return reply.code(400).send({ code: 'bad_request', error: 'Invalid ownerType' }) } } if (request.session.User) { request.teamMembership = await request.session.User.getTeamMembership(request.owner.TeamId) if (!request.teamMembership && !request.session.User.admin) { return reply.code(404).send({ code: 'not_found', error: 'Not Found' }) } } else { return reply.code(401).send({ code: 'unauthorized', error: 'Unauthorized' }) } } catch (err) { reply.code(404).send({ code: 'not_found', error: 'Not Found' }) } }) /** * Get a snapshot - metadata only */ app.get('/:id', { preHandler: app.needsPermission('snapshot:meta'), schema: { summary: 'Get summary of a snapshot', tags: ['Snapshots'], params: { type: 'object', properties: { id: { type: 'string' } } }, response: { 200: { $ref: 'Snapshot' }, '4xx': { $ref: 'APIError' } } } }, async (request, reply) => { // reload the snapshot to get the full details, including the User & Device/Project // these are needed for viewer permissions on download "package.json" action since it // needs the owner project/device & modules in the snapshot settings to generate it. // Flows/settings/env are NOT included in the metadata response thanks to to the schema/view await request.snapshot.reload({ include: ['User', 'Device', 'Project'] }) reply.send(projectSnapshotView.snapshot(request.snapshot)) }) /** * Get details of a snapshot - full details */ app.get('/:id/full', { preHandler: app.needsPermission('snapshot:full'), schema: { summary: 'Get details of a snapshot', tags: ['Snapshots'], params: { type: 'object', properties: { id: { type: 'string' } } }, response: { 200: { $ref: 'FullSnapshot' // identical to ExportedSnapshot but excludes credentials }, '4xx': { $ref: 'APIError' } } } }, async (request, reply) => { const snapshot = { ...(request.snapshot.toJSON ? request.snapshot.toJSON() : request.snapshot) } reply.send(projectSnapshotView.snapshotExport(snapshot)) }) /** * Delete a snapshot */ app.delete('/:id', { preHandler: app.needsPermission('snapshot:delete'), schema: { summary: 'Delete a snapshot', tags: ['Snapshots'], params: { type: 'object', properties: { id: { type: 'string' } } }, response: { 200: { $ref: 'APIStatus' }, '4xx': { $ref: 'APIError' } } } }, async (request, reply) => { await snapshotController.deleteSnapshot(request.snapshot) if (request.ownerType === 'device') { const application = await request.owner.getApplication() await applicationLogger.application.device.snapshot.deleted(request.session.User, null, application, request.owner, request.snapshot) } else if (request.ownerType === 'instance') { await projectLogger.project.snapshot.deleted(request.session.User, null, request.owner, request.snapshot) } reply.send({ status: 'okay' }) }) /** * Update a snapshot */ app.put('/:id', { preHandler: app.needsPermission('snapshot:edit'), schema: { summary: 'Update a snapshot', tags: ['Snapshots'], params: { type: 'object', properties: { id: { type: 'string' } } }, body: { type: 'object', properties: { name: { type: 'string' }, description: { type: 'string' } } }, response: { 200: { $ref: 'Snapshot' }, '4xx': { $ref: 'APIError' } } } }, async (request, reply) => { // capture the original name/description for the audit log const snapshotBefore = { name: request.snapshot.name, description: request.snapshot.description } // perform the update const snapshot = await snapshotController.updateSnapshot(request.snapshot, request.body) // log the update const snapshotAfter = { name: snapshot.name, description: snapshot.description } const updates = new UpdatesCollection() updates.pushDifferences(snapshotBefore, snapshotAfter) if (request.ownerType === 'device') { const application = await request.owner.getApplication() await applicationLogger.application.device.snapshot.updated(request.session.User, null, application, request.owner, request.snapshot, updates) } else if (request.ownerType === 'instance') { await projectLogger.project.snapshot.updated(request.session.User, null, request.owner, request.snapshot, updates) } reply.send(projectSnapshotView.snapshot(snapshot)) }) /** * Export a snapshot for later import in another project or platform */ app.post('/:id/export', { preHandler: app.needsPermission('snapshot:export'), schema: { summary: 'Export a snapshot', tags: ['Snapshots'], params: { type: 'object', properties: { id: { type: 'string' } } }, body: { type: 'object', properties: { credentialSecret: { type: 'string' }, components: { type: 'object', properties: { flows: { type: 'boolean', default: true }, credentials: { type: 'boolean', default: true }, envVars: { anyOf: [ { type: 'string', enum: ['all', 'keys'] }, { type: 'boolean', enum: [false] } ] } } } } }, response: { 200: { $ref: 'ExportedSnapshot' // // identical to FullSnapshot but includes credentials }, '4xx': { $ref: 'APIError' } } } }, async (request, reply) => { const options = { credentialSecret: request.body.credentialSecret, credentials: request.body.credentials, owner: request.owner, // the instance or device that owns the snapshot components: request.body.components } if (!options.credentialSecret) { reply.code(400).send({ code: 'bad_request', error: 'credentialSecret is mandatory in the body' }) return } const snapshot = await snapshotController.exportSnapshot(request.snapshot, options) if (snapshot) { const snapshotExport = projectSnapshotView.snapshotExport(snapshot, request.session.User) if (request.ownerType === 'device') { const application = await request.owner.getApplication() await applicationLogger.application.device.snapshot.exported(request.session.User, null, application, request.owner, request.snapshot) } else if (request.ownerType === 'instance') { await projectLogger.project.snapshot.exported(request.session.User, null, request.owner, request.snapshot) } reply.send(snapshotExport) } else { reply.send({}) } }) /** * Import a snapshot */ app.post('/import', { preHandler: app.needsPermission('snapshot:import'), schema: { summary: 'Upload a snapshot', tags: ['Snapshots'], body: { type: 'object', properties: { ownerId: { type: 'string' }, ownerType: { type: 'string' }, snapshot: { type: 'object', properties: { name: { type: 'string' }, description: { type: 'string' }, flows: { type: 'object', properties: { flows: { type: 'array', items: {}, minItems: 0 }, credentials: { type: 'object' } }, required: ['flows'] }, settings: { type: 'object', properties: { settings: { type: 'object' }, env: { type: 'object' }, modules: { type: 'object' } }, required: [] } }, required: ['name', 'flows', 'settings'] }, credentialSecret: { type: 'string' }, components: { type: 'object', properties: { flows: { type: 'boolean', default: true }, credentials: { type: 'boolean', default: true }, envVars: { anyOf: [ { type: 'string', enum: ['all', 'keys'] }, { type: 'boolean', enum: [false] } ] } } } } }, response: { 200: { $ref: 'Snapshot' }, '4xx': { $ref: 'APIError' } } } }, async (request, reply) => { const owner = request.owner const snapshot = request.body.snapshot if (!owner || !snapshot) { reply.code(400).send({ code: 'bad_request', error: 'owner and snapshot are mandatory in the body' }) return } if (request.body.components?.credentials !== false) { if (snapshot.flows.credentials?.$ && !request.body.credentialSecret) { reply.code(400).send({ code: 'bad_request', error: 'Credential secret is required when importing a snapshot with credentials' }) return } } try { const options = { components: request.body.components || null } const newSnapshot = await snapshotController.uploadSnapshot(owner, snapshot, request.body.credentialSecret, request.session.User, options) if (!newSnapshot) { throw new Error('Failed to upload snapshot') } // reload the snapshot to get the full details, including the User & Device/Project await newSnapshot.reload({ include: ['User', 'Device', 'Project'] }) if (request.ownerType === 'device') { const application = await owner.getApplication() await applicationLogger.application.device.snapshot.imported(request.session.User, null, application, owner, null, null, newSnapshot) } else if (request.ownerType === 'instance') { await projectLogger.project.snapshot.imported(request.session.User, null, owner, null, null, newSnapshot) } reply.send(projectSnapshotView.snapshot(newSnapshot)) } catch (err) { // if err message is a JSON.parse failure in decryptCreds, it's a bad secret if (/JSON\.parse.*decryptCreds/si.test(err.stack)) { return reply.code(400).send({ code: 'bad_request', error: 'Invalid credential secret' }) } throw err // handled by global error handler } }) }