This repository has been archived by the owner on Jan 7, 2022. It is now read-only.
-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
3 changed files
with
418 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,252 @@ | ||
const Superagent = require('superagent'); | ||
const Url = require('url'); | ||
|
||
function debug() { | ||
if (process.env.debug) { | ||
console.log.apply(console, arguments); | ||
} | ||
}; | ||
|
||
function validateReturnTo(ctx) { | ||
if (ctx.query.returnTo) { | ||
const validReturnTo = (ctx.configuration.fusebit_allowed_return_to || '').split(','); | ||
const match = validReturnTo.find(allowed => { | ||
if (allowed === ctx.query.returnTo) { | ||
return true; | ||
} | ||
if (allowed[allowed.length - 1] === '*' && ctx.query.returnTo.indexOf(allowed.substring(0, allowed.length - 1)) === 0) { | ||
return true; | ||
} | ||
return false; | ||
}); | ||
if (!match) { | ||
throw { | ||
status: 403, | ||
message: `The specified 'returnTo' URL '${ctx.query.returnTo}' does not match any of the allowed returnTo URLs of the '${ | ||
ctx.boundaryId}/${ctx.functionId}' Fusebit Add-On component. If this is a valid request, add the specified 'returnTo' URL to the 'fusebit_allowed_return_to' configuration property of the '${ | ||
ctx.boundaryId}/${ctx.functionId}' Fusebit Add-On component.` | ||
}; | ||
} | ||
} | ||
} | ||
|
||
exports.debug = debug; | ||
|
||
exports.createSettingsManager = (configure, disableDebug) => { | ||
const { states, initialState } = configure; | ||
return async (ctx) => { | ||
if (!disableDebug) { | ||
debug('DEBUGGING ENABLED. To disable debugging information, comment out the `debug` configuration setting.'); | ||
debug('NEW REQUEST', ctx.method, ctx.url, ctx.query, ctx.body); | ||
} | ||
try { | ||
// Configuration request | ||
validateReturnTo(ctx); | ||
let [state, data] = exports.getInputs(ctx, initialState || 'none'); | ||
debug('STATE', state); | ||
debug('DATA', data); | ||
if (ctx.query.status === 'error') { | ||
// This is a callback from a subordinate service that resulted in an error; propagate | ||
throw { status: data.status || 500, message: data.message || 'Unspecified error', state }; | ||
} | ||
let stateHandler = states[state.configurationState]; | ||
if (stateHandler) { | ||
return await stateHandler(ctx, state, data); | ||
} | ||
else { | ||
throw { status: 400, message: `Unsupported configuration state '${state.configurationState}'`, state }; | ||
} | ||
} | ||
catch (e) { | ||
return exports.completeWithError(ctx, e); | ||
} | ||
} | ||
}; | ||
|
||
exports.createLifecycleManager = (options) => { | ||
const { configure, install, uninstall } = options; | ||
return async (ctx) => { | ||
debug('DEBUGGING ENABLED. To disable debugging information, comment out the `debug` configuration setting.'); | ||
debug('NEW REQUEST', ctx.method, ctx.url, ctx.query, ctx.body); | ||
const pathSegments = Url.parse(ctx.url).pathname.split('/'); | ||
let lastSegment; | ||
do { | ||
lastSegment = pathSegments.pop(); | ||
} while (!lastSegment && pathSegments.length > 0); | ||
try { | ||
switch (lastSegment) { | ||
case 'configure': // configuration | ||
if (configure) { | ||
// There is a configuration stage, process the next step in the configuration | ||
validateReturnTo(ctx); | ||
const settingsManager = exports.createSettingsManager(configure, true); | ||
return await settingsManager(ctx); | ||
} | ||
else { | ||
// There is no configuration stage, simply redirect back to the caller with success | ||
validateReturnTo(ctx); | ||
let [state, data] = exports.getInputs(ctx, configure && configure.initialState || 'none'); | ||
return exports.completeWithSuccess(state, data); | ||
} | ||
break; | ||
case 'install': // installation | ||
if (!install) { | ||
throw { status: 404, message: 'Not found' }; | ||
} | ||
return await install(ctx); | ||
case 'uninstall': // uninstallation | ||
if (!uninstall) { | ||
throw { status: 404, message: 'Not found' }; | ||
} | ||
return await uninstall(ctx); | ||
default: | ||
throw { status: 404, message: 'Not found' }; | ||
}; | ||
} | ||
catch (e) { | ||
return exports.completeWithError(ctx, e); | ||
} | ||
}; | ||
} | ||
|
||
exports.serializeState = (state) => Buffer.from(JSON.stringify(state)).toString('base64'); | ||
|
||
exports.deserializeState = (state) => JSON.parse(Buffer.from(state, 'base64').toString()); | ||
|
||
exports.getInputs = (ctx, initialConfigurationState) => { | ||
let data; | ||
try { | ||
data = ctx.query.data ? exports.deserializeState(ctx.query.data) : {}; | ||
} | ||
catch (e) { | ||
throw { status: 400, message: `Malformed 'data' parameter` }; | ||
} | ||
if (ctx.query.returnTo) { | ||
// Initialization of the add-on component interaction | ||
if (!initialConfigurationState) { | ||
throw { status: 400, message: `State consistency error. Initial configuration state is not specified, and 'state' parameter is missing.` }; | ||
} | ||
['baseUrl', 'accountId', 'subscriptionId', 'boundaryId','functionId', 'templateName'].forEach(p => { | ||
if (!data[p]) { | ||
throw { status: 400, message: `Missing 'data.${p}' input parameter`, state: ctx.query.state }; | ||
} | ||
}); | ||
return [{ configurationState: initialConfigurationState, returnTo: ctx.query.returnTo, returnToState: ctx.query.state}, data]; | ||
} | ||
else if (ctx.query.state) { | ||
// Continuation of the add-on component interaction (e.g. form post from a settings manager) | ||
try { | ||
return [JSON.parse(Buffer.from(ctx.query.state, 'base64').toString()), data]; | ||
} | ||
catch (e) { | ||
throw { status: 400, message: `Malformed 'state' parameter` }; | ||
} | ||
} else { | ||
throw { status: 400, message: `Either the 'returnTo' or 'state' parameter must be present.` }; | ||
} | ||
} | ||
|
||
exports.completeWithSuccess = (state, data) => { | ||
const location = `${state.returnTo}?status=success&data=${ | ||
encodeURIComponent(exports.serializeState(data)) | ||
}` + (state.returnToState ? `&state=${encodeURIComponent(state.returnToState)}` : ''); | ||
return { status: 302, headers: { location }}; | ||
}; | ||
|
||
exports.completeWithError = (ctx, error) => { | ||
debug('COMPLETE WITH ERROR', error); | ||
let returnTo = error.state && error.state.returnTo || ctx.query.returnTo; | ||
let state = error.state && error.state.returnToState || (ctx.query.returnTo && ctx.query.state); | ||
let body = { status: error.status || 500, message: error.message }; | ||
if (returnTo) { | ||
const location = `${returnTo}?status=error&data=${ | ||
encodeURIComponent(exports.serializeState(body)) | ||
}` + (state ? `&state=${encodeURIComponent(state)}` : ''); | ||
return { status: 302, headers: { location }}; | ||
} | ||
else { | ||
return { status: body.status, body }; | ||
} | ||
}; | ||
|
||
exports.getSelfUrl = (ctx) => { | ||
const baseUrl = ctx.headers['x-forwarded-proto'] | ||
? `${ctx.headers['x-forwarded-proto'].split(',')[0]}://${ctx.headers.host}` | ||
: `${ctx.protocol}://${ctx.headers.host}`; | ||
return `${baseUrl}/v1/run/${ctx.subscriptionId}/${ctx.boundaryId}/${ctx.functionId}`; | ||
}; | ||
|
||
exports.redirect = (ctx, state, data, redirectUrl, nextConfigurationState) => { | ||
|
||
state.configurationState = nextConfigurationState; | ||
|
||
const location = `${ | ||
redirectUrl | ||
}?returnTo=${ | ||
`${exports.getSelfUrl(ctx)}/configure` | ||
}&state=${ | ||
encodeURIComponent(exports.serializeState(state)) | ||
}&data=${ | ||
encodeURIComponent(exports.serializeState(data)) | ||
}`; | ||
|
||
return { status: 302, headers: { location } }; | ||
} | ||
|
||
exports.createFunction = async (ctx, functionSpecification) => { | ||
let functionCreated = false; | ||
try { | ||
let url = `${ctx.body.baseUrl}/v1/account/${ctx.body.accountId}/subscription/${ | ||
ctx.body.subscriptionId | ||
}/boundary/${ctx.body.boundaryId}/function/${ctx.body.functionId}`; | ||
let response = await Superagent.put(url) | ||
.set("Authorization", ctx.headers['authorization']) // pass-through authorization | ||
.send(functionSpecification); | ||
functionCreated = true; | ||
let attempts = 15; | ||
while (response.status === 201 && attempts > 0) { | ||
response = await Superagent.get( | ||
`${ctx.body.baseUrl}/v1/account/${ctx.body.accountId}/subscription/${ | ||
ctx.body.subscriptionId | ||
}/boundary/${ctx.body.boundaryId}/function/${ctx.body.functionId}/build/${response.body.buildId}` | ||
).set("Authorization", ctx.headers['authorization']); | ||
if (response.status === 200) { | ||
if (response.body.status === "success") { | ||
return; | ||
} else { | ||
throw new Error( | ||
`Failure creating function: ${(response.body.error && | ||
response.body.error.message) || | ||
"Unknown error"}` | ||
); | ||
} | ||
} | ||
await new Promise(resolve => setTimeout(resolve, 2000)); | ||
attempts--; | ||
} | ||
if (attempts === 0) { | ||
throw new Error(`Timeout creating function`); | ||
} | ||
if (response.status === 204 || (response.body && response.body.status === "success")) { | ||
return; | ||
} else { | ||
throw response.body; | ||
} | ||
} | ||
catch (e) { | ||
if (functionCreated) { | ||
try { | ||
await exports.deleteFunction(ctx); | ||
} catch (_) {} | ||
} | ||
throw e; | ||
} | ||
}; | ||
|
||
exports.deleteFunction = async (ctx, boundaryId, functionId) => { | ||
await Superagent.delete( | ||
`${ctx.body.baseUrl}/v1/account/${ctx.body.accountId}/subscription/${ | ||
ctx.body.subscriptionId | ||
}/boundary/${boundaryId || ctx.body.boundaryId}/function/${functionId || ctx.body.functionId}` | ||
).set("Authorization", ctx.headers['authorization']); // pass-through authorization | ||
}; |
Oops, something went wrong.