chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
import * as sdk from '@botpress/sdk'
|
||||
import * as crypto from 'crypto'
|
||||
import { getOAuthClientSecret } from '../dropbox-api/oauth-client'
|
||||
import { handleFileChangeEvent, isFileChangeNotification } from './handlers/file-change'
|
||||
import { isWebhookVerificationRequest, handleWebhookVerificationRequest } from './handlers/webhook-verification'
|
||||
import { oauthCallbackHandler } from './oauth'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export const handler: bp.IntegrationProps['handler'] = async (props) => {
|
||||
if (props.req.path.startsWith('/oauth')) {
|
||||
return await oauthCallbackHandler(props)
|
||||
}
|
||||
|
||||
if (isWebhookVerificationRequest(props)) {
|
||||
return await handleWebhookVerificationRequest(props)
|
||||
}
|
||||
|
||||
_validatePayloadSignature(props)
|
||||
|
||||
if (isFileChangeNotification(props)) {
|
||||
return await handleFileChangeEvent(props)
|
||||
}
|
||||
|
||||
throw new sdk.RuntimeError('Unsupported webhook event')
|
||||
}
|
||||
|
||||
const _validatePayloadSignature = (props: bp.HandlerProps) => {
|
||||
const bodySignatureFromDropbox = props.req.headers['X-Dropbox-Signature'] ?? props.req.headers['x-dropbox-signature']
|
||||
|
||||
if (!bodySignatureFromDropbox) {
|
||||
throw new sdk.RuntimeError('Missing Dropbox signature in request headers')
|
||||
}
|
||||
|
||||
const clientSecret = getOAuthClientSecret({ ctx: props.ctx })
|
||||
const bodySignatureFromBotpress = crypto
|
||||
.createHmac('sha256', clientSecret)
|
||||
.update(props.req.body ?? '')
|
||||
.digest('hex')
|
||||
|
||||
if (bodySignatureFromDropbox !== bodySignatureFromBotpress) {
|
||||
throw new sdk.RuntimeError('Dropbox signature does not match the expected signature')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import * as sdk from '@botpress/sdk'
|
||||
import { File as FileEntity, Folder as FolderEntity, Deleted as DeletedEntity } from '../../../definitions'
|
||||
import { DropboxClient } from '../../dropbox-api'
|
||||
import { FileTree, type FileTreeDiff } from '../../dropbox-api/file-tree'
|
||||
import * as filesReadonlyMapping from '../../files-readonly/mapping'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
const NOTIFICATION_PAYLOAD = sdk.z.object({
|
||||
list_folder: sdk.z.object({
|
||||
accounts: sdk.z.array(sdk.z.string()),
|
||||
}),
|
||||
})
|
||||
|
||||
export const isFileChangeNotification = (props: bp.HandlerProps) =>
|
||||
props.req.method.toUpperCase() === 'POST' &&
|
||||
props.req.body &&
|
||||
NOTIFICATION_PAYLOAD.safeParse(JSON.parse(props.req.body)).success
|
||||
|
||||
export const handleFileChangeEvent: bp.IntegrationProps['handler'] = async (props) => {
|
||||
const payload: sdk.z.infer<typeof NOTIFICATION_PAYLOAD> = JSON.parse(props.req.body!)
|
||||
const dropboxClient = await DropboxClient.create(props)
|
||||
const accountId = dropboxClient.getAccountId()
|
||||
|
||||
if (!payload.list_folder.accounts.includes(accountId)) {
|
||||
// If the account ID is not in the list of accounts, we can ignore this notification
|
||||
return
|
||||
}
|
||||
|
||||
const { syncCursor: prevSyncCursor, fileTree } = await _getSyncState(props)
|
||||
|
||||
const { newSyncCursor } = await _collectChangesAndBroadcast(props, prevSyncCursor, fileTree)
|
||||
await _updateSyncState(props, newSyncCursor, fileTree)
|
||||
}
|
||||
|
||||
const _getSyncState = async (props: bp.HandlerProps): Promise<{ syncCursor?: string; fileTree: FileTree }> => {
|
||||
const { state } = await props.client.getOrSetState({
|
||||
id: props.ctx.integrationId,
|
||||
type: 'integration',
|
||||
name: 'realTimeSync',
|
||||
payload: {
|
||||
syncCursor: '',
|
||||
fileTreeJson: '[]',
|
||||
},
|
||||
})
|
||||
|
||||
return { syncCursor: state.payload.syncCursor || undefined, fileTree: FileTree.fromJSON(state.payload.fileTreeJson) }
|
||||
}
|
||||
|
||||
const _updateSyncState = async (props: bp.HandlerProps, syncCursor: string, fileTree: FileTree) => {
|
||||
await props.client.setState({
|
||||
id: props.ctx.integrationId,
|
||||
type: 'integration',
|
||||
name: 'realTimeSync',
|
||||
payload: {
|
||||
syncCursor,
|
||||
fileTreeJson: fileTree.toJSON(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const _collectChangesAndBroadcast = async (
|
||||
props: bp.HandlerProps,
|
||||
prevSyncCursor: string | undefined,
|
||||
fileTree: FileTree
|
||||
) => {
|
||||
const { items: modifiedItems, newSyncCursor } = await _getModifiedItems(props, prevSyncCursor)
|
||||
|
||||
const diff = fileTree.pushItems(modifiedItems)
|
||||
const integrationSetupDate = await _getIntegrationSetupDate(props)
|
||||
|
||||
const isFileModifiedAfterSetup = (item: FileEntity.InferredType | FolderEntity.InferredType) =>
|
||||
item.itemType === 'file' &&
|
||||
// If it is the first sync (prevSyncCursor is undefined), omit files that
|
||||
// were created/modified before the integration was set up:
|
||||
(prevSyncCursor ? true : !item.modifiedAt || new Date(item.modifiedAt) > integrationSetupDate)
|
||||
|
||||
await _broadcastChanges(props, {
|
||||
deleted: diff.deleted,
|
||||
updated: diff.updated.filter(isFileModifiedAfterSetup),
|
||||
added: diff.added.filter(isFileModifiedAfterSetup),
|
||||
})
|
||||
|
||||
return { newSyncCursor }
|
||||
}
|
||||
|
||||
const _getModifiedItems = async (props: bp.HandlerProps, prevSyncCursor: string | undefined) => {
|
||||
const dropboxClient = await DropboxClient.create(props)
|
||||
|
||||
let currentSyncCursor: string | undefined = prevSyncCursor
|
||||
let hasMore: boolean = false
|
||||
const items: (FileEntity.InferredType | FolderEntity.InferredType | DeletedEntity.InferredType)[] = []
|
||||
do {
|
||||
const itemBatch = await dropboxClient.listItemsInFolder({ path: '', recursive: true, nextToken: currentSyncCursor })
|
||||
items.push(...itemBatch.items)
|
||||
currentSyncCursor = itemBatch.nextToken
|
||||
hasMore = itemBatch.hasMore
|
||||
} while (hasMore)
|
||||
|
||||
return {
|
||||
newSyncCursor: currentSyncCursor,
|
||||
items,
|
||||
}
|
||||
}
|
||||
|
||||
const _getIntegrationSetupDate = async (props: bp.HandlerProps): Promise<Date> => {
|
||||
try {
|
||||
const { state } = await props.client.getState({
|
||||
type: 'integration',
|
||||
id: props.ctx.integrationId,
|
||||
name: 'setupMeta',
|
||||
})
|
||||
|
||||
return new Date(state.payload.integrationRegisteredAt)
|
||||
} catch {
|
||||
return new Date(0)
|
||||
}
|
||||
}
|
||||
|
||||
const _broadcastChanges = async (props: bp.HandlerProps, fileTreeDiff: FileTreeDiff) => {
|
||||
for (const diffBatch of _getDiffBatches(fileTreeDiff)) {
|
||||
await props.client.createEvent({
|
||||
type: 'aggregateFileChanges',
|
||||
payload: {
|
||||
modifiedItems: {
|
||||
created: (diffBatch.added as FileEntity.InferredType[]).map(filesReadonlyMapping.mapFile),
|
||||
updated: (diffBatch.updated as FileEntity.InferredType[]).map(filesReadonlyMapping.mapFile),
|
||||
deleted: diffBatch.deleted.map((item) =>
|
||||
item.itemType === 'file' ? filesReadonlyMapping.mapFile(item) : filesReadonlyMapping.mapFolder(item)
|
||||
),
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const _getDiffBatches = function* ({ added, updated, deleted }: FileTreeDiff) {
|
||||
const MAX_BATCH_SIZE = 50
|
||||
|
||||
let addedCursor = 0
|
||||
let updatedCursor = 0
|
||||
let deletedCursor = 0
|
||||
|
||||
// Continue until all arrays are exhausted:
|
||||
while (addedCursor < added.length || updatedCursor < updated.length || deletedCursor < deleted.length) {
|
||||
const currentBatch: FileTreeDiff = { added: [], deleted: [], updated: [] }
|
||||
|
||||
for (let currentBatchSize = 0; currentBatchSize < MAX_BATCH_SIZE; ) {
|
||||
const startSize = currentBatchSize
|
||||
|
||||
if (addedCursor < added.length && currentBatchSize < MAX_BATCH_SIZE) {
|
||||
currentBatch.added.push(added[addedCursor++]!)
|
||||
currentBatchSize++
|
||||
}
|
||||
|
||||
if (updatedCursor < updated.length && currentBatchSize < MAX_BATCH_SIZE) {
|
||||
currentBatch.updated.push(updated[updatedCursor++]!)
|
||||
currentBatchSize++
|
||||
}
|
||||
|
||||
if (deletedCursor < deleted.length && currentBatchSize < MAX_BATCH_SIZE) {
|
||||
currentBatch.deleted.push(deleted[deletedCursor++]!)
|
||||
currentBatchSize++
|
||||
}
|
||||
|
||||
// If no item was added, the array is exhausted:
|
||||
if (currentBatchSize === startSize) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
yield currentBatch
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export const isWebhookVerificationRequest = (props: bp.HandlerProps) => {
|
||||
const searchParams = new URLSearchParams(props.req.query)
|
||||
|
||||
return props.req.method === 'GET' && searchParams.has('challenge')
|
||||
}
|
||||
|
||||
export const handleWebhookVerificationRequest: bp.IntegrationProps['handler'] = async ({ req }) => {
|
||||
const searchParams = new URLSearchParams(req.query)
|
||||
const challenge = searchParams.get('challenge')
|
||||
|
||||
if (!challenge) {
|
||||
return
|
||||
}
|
||||
|
||||
return {
|
||||
headers: { 'Content-Type': 'text/plain', 'X-Content-Type-Options': 'nosniff' },
|
||||
status: 200,
|
||||
body: challenge,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './handler-dispatcher'
|
||||
@@ -0,0 +1,22 @@
|
||||
import { isOAuthWizardUrl, getInterstitialUrl, generateRedirection } from '@botpress/common/src/oauth-wizard'
|
||||
import * as wizard from './wizard'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export const oauthCallbackHandler: bp.IntegrationProps['handler'] = async (props) => {
|
||||
const { req, logger } = props
|
||||
if (!isOAuthWizardUrl(req.path)) {
|
||||
return {
|
||||
status: 404,
|
||||
body: 'Invalid OAuth endpoint',
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return await wizard.handler(props)
|
||||
} catch (thrown: unknown) {
|
||||
const error = thrown instanceof Error ? thrown : Error(String(thrown))
|
||||
const errorMessage = 'OAuth registration Error: ' + error.message
|
||||
logger.forBot().error(errorMessage)
|
||||
return generateRedirection(getInterstitialUrl(false, errorMessage))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import * as oauthWizard from '@botpress/common/src/oauth-wizard'
|
||||
import { DropboxOAuthClient, getOAuthClientId } from '../../dropbox-api/oauth-client'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
type WizardHandler = oauthWizard.WizardStepHandler<bp.HandlerProps>
|
||||
|
||||
export const handler = async (props: bp.HandlerProps) => {
|
||||
const wizard = new oauthWizard.OAuthWizardBuilder(props)
|
||||
.addStep({ id: 'start-confirm', handler: _startHandler })
|
||||
.addStep({ id: 'redirect-to-dropbox', handler: _redirectToDropboxHandler })
|
||||
.addStep({ id: 'oauth-callback', handler: _oauthCallbackHandler })
|
||||
.addStep({ id: 'end', handler: _endHandler })
|
||||
.build()
|
||||
|
||||
const response = await wizard.handleRequest()
|
||||
return response
|
||||
}
|
||||
|
||||
const _startHandler: WizardHandler = async (props) => {
|
||||
const { responses } = props
|
||||
|
||||
// When nothing is connected yet there's nothing to reset, so skip the
|
||||
// confirmation and go straight to authorizing with Dropbox.
|
||||
if (!(await _isAlreadyConnected(props))) {
|
||||
return _redirectToDropboxHandler(props)
|
||||
}
|
||||
|
||||
return responses.displayButtons({
|
||||
pageTitle: 'Reset Configuration',
|
||||
htmlOrMarkdownPageContents:
|
||||
'This wizard will reset your configuration, so the bot will stop working on Dropbox until a new configuration is put in place, continue?',
|
||||
buttons: [
|
||||
{
|
||||
action: 'navigate',
|
||||
label: 'Yes',
|
||||
navigateToStep: 'redirect-to-dropbox',
|
||||
buttonType: 'primary',
|
||||
},
|
||||
{
|
||||
action: 'close',
|
||||
label: 'No',
|
||||
buttonType: 'secondary',
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
const _isAlreadyConnected = async ({ client, ctx }: bp.HandlerProps): Promise<boolean> => {
|
||||
try {
|
||||
const result = await client.getState({
|
||||
id: ctx.integrationId,
|
||||
type: 'integration',
|
||||
name: 'authorization',
|
||||
})
|
||||
return Boolean(result?.state?.payload?.refreshToken)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const _redirectToDropboxHandler: WizardHandler = async (props) => {
|
||||
const { responses, ctx } = props
|
||||
const clientId = getOAuthClientId({ ctx })
|
||||
|
||||
if (!clientId) {
|
||||
return responses.endWizard({
|
||||
success: false,
|
||||
errorMessage: 'Dropbox App Key (APP_KEY) is not configured. Please configure it in the integration settings.',
|
||||
})
|
||||
}
|
||||
|
||||
const redirectUri = _getOAuthRedirectUri()
|
||||
|
||||
const dropboxAuthUrl =
|
||||
'https://www.dropbox.com/oauth2/authorize?' +
|
||||
'response_type=code' +
|
||||
'&token_access_type=offline' +
|
||||
`&client_id=${encodeURIComponent(clientId)}` +
|
||||
`&redirect_uri=${encodeURIComponent(redirectUri)}` +
|
||||
`&state=${encodeURIComponent(ctx.webhookId)}`
|
||||
|
||||
return responses.redirectToExternalUrl(dropboxAuthUrl)
|
||||
}
|
||||
|
||||
const _getOAuthRedirectUri = (ctx?: bp.Context) => oauthWizard.getWizardStepUrl('oauth-callback', ctx).toString()
|
||||
|
||||
const _oauthCallbackHandler: WizardHandler = async (props) => {
|
||||
const { responses, query, client, ctx, logger, setIntegrationIdentifier } = props
|
||||
|
||||
const oauthError = query.get('error')
|
||||
const oauthErrorDescription = query.get('error_description')
|
||||
if (oauthError) {
|
||||
const errorMessage = oauthErrorDescription
|
||||
? `OAuth error: ${oauthError} - ${oauthErrorDescription}`
|
||||
: `OAuth error: ${oauthError}`
|
||||
logger.forBot().warn(errorMessage)
|
||||
return responses.endWizard({
|
||||
success: false,
|
||||
errorMessage,
|
||||
})
|
||||
}
|
||||
|
||||
const authorizationCode = query.get('code')
|
||||
if (!authorizationCode) {
|
||||
const errorMessage =
|
||||
'No authorization code received from Dropbox. ' +
|
||||
'This may happen if you denied the authorization request, ' +
|
||||
'if the authorization code expired, or if there was an error during the OAuth flow. ' +
|
||||
'Please try authorizing again.'
|
||||
logger.forBot().warn(errorMessage)
|
||||
return responses.endWizard({
|
||||
success: false,
|
||||
errorMessage,
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
const redirectUri = _getOAuthRedirectUri()
|
||||
const oauthClient = new DropboxOAuthClient({ client, ctx })
|
||||
await oauthClient.processAuthorizationCode(authorizationCode, redirectUri)
|
||||
logger.forBot().info('Successfully exchanged authorization code for refresh token')
|
||||
|
||||
setIntegrationIdentifier(ctx.webhookId)
|
||||
|
||||
return responses.endWizard({
|
||||
success: true,
|
||||
})
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error
|
||||
? `Failed to process authorization code: ${error.message}. Please make sure the code is correct and hasn't expired.`
|
||||
: 'Failed to process authorization code. Please try again.'
|
||||
logger.forBot().error({ err: error }, errorMessage)
|
||||
return responses.endWizard({
|
||||
success: false,
|
||||
errorMessage,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const _endHandler: WizardHandler = ({ responses }) => {
|
||||
return responses.endWizard({
|
||||
success: true,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user