first commit
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,164 @@
|
||||
import type { INodeProperties } from 'n8n-workflow';
|
||||
|
||||
export const fileOperations: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Operation',
|
||||
name: 'operation',
|
||||
type: 'options',
|
||||
noDataExpression: true,
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['file'],
|
||||
},
|
||||
},
|
||||
options: [
|
||||
{
|
||||
name: 'Create',
|
||||
value: 'create',
|
||||
description: 'Create a file',
|
||||
action: 'Create a file',
|
||||
},
|
||||
{
|
||||
name: 'Delete',
|
||||
value: 'delete',
|
||||
description: 'Delete file',
|
||||
action: 'Delete a file',
|
||||
},
|
||||
{
|
||||
name: 'Get',
|
||||
value: 'get',
|
||||
description: 'Get a file content',
|
||||
action: 'Get a file content',
|
||||
},
|
||||
{
|
||||
name: 'Get Many',
|
||||
value: 'getAll',
|
||||
description: 'Get many files',
|
||||
action: 'Get many files',
|
||||
},
|
||||
],
|
||||
default: 'get',
|
||||
},
|
||||
];
|
||||
|
||||
export const fileFields: INodeProperties[] = [
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* file:* */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
{
|
||||
displayName: 'Form Name or ID',
|
||||
name: 'formId',
|
||||
type: 'options',
|
||||
typeOptions: {
|
||||
loadOptionsMethod: 'loadForms',
|
||||
},
|
||||
required: true,
|
||||
default: '',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['file'],
|
||||
},
|
||||
},
|
||||
description:
|
||||
'Form ID (e.g. aSAvYreNzVEkrWg5Gdcvg). Choose from the list, or specify an ID using an <a href="https://docs.n8n.io/code/expressions/">expression</a>.',
|
||||
},
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* file:delete */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
{
|
||||
displayName: 'File ID',
|
||||
name: 'fileId',
|
||||
type: 'string',
|
||||
required: true,
|
||||
default: '',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['file'],
|
||||
operation: ['delete', 'get'],
|
||||
},
|
||||
},
|
||||
description: 'Uid of the file (should start with "af" e.g. "afQoJxA4kmKEXVpkH6SYbhb"',
|
||||
},
|
||||
{
|
||||
displayName: 'Property Name',
|
||||
name: 'binaryPropertyName',
|
||||
type: 'string',
|
||||
required: true,
|
||||
default: 'data',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['file'],
|
||||
operation: ['get'],
|
||||
},
|
||||
},
|
||||
description: 'Name of the binary property to write the file into',
|
||||
},
|
||||
{
|
||||
displayName: 'Download File Content',
|
||||
name: 'download',
|
||||
type: 'boolean',
|
||||
required: true,
|
||||
default: false,
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['file'],
|
||||
operation: ['get'],
|
||||
},
|
||||
},
|
||||
description: 'Whether to download the file content into a binary property',
|
||||
},
|
||||
{
|
||||
displayName: 'File Upload Mode',
|
||||
name: 'fileMode',
|
||||
type: 'options',
|
||||
required: true,
|
||||
default: 'binary',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['file'],
|
||||
operation: ['create'],
|
||||
},
|
||||
},
|
||||
options: [
|
||||
{
|
||||
name: 'Binary File',
|
||||
value: 'binary',
|
||||
},
|
||||
{
|
||||
name: 'URL',
|
||||
value: 'url',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
displayName: 'Property Name',
|
||||
name: 'binaryPropertyName',
|
||||
type: 'string',
|
||||
required: true,
|
||||
default: 'data',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['file'],
|
||||
operation: ['create'],
|
||||
fileMode: ['binary'],
|
||||
},
|
||||
},
|
||||
description:
|
||||
'Name of the binary property containing the file to upload. Supported types: image, audio, video, csv, xml, zip.',
|
||||
},
|
||||
{
|
||||
displayName: 'File URL',
|
||||
name: 'fileUrl',
|
||||
type: 'string',
|
||||
required: true,
|
||||
default: '',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['file'],
|
||||
operation: ['create'],
|
||||
fileMode: ['url'],
|
||||
},
|
||||
},
|
||||
description: 'HTTP(s) link to the file to upload',
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,186 @@
|
||||
import type { INodeProperties } from 'n8n-workflow';
|
||||
|
||||
export const formOperations: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Operation',
|
||||
name: 'operation',
|
||||
type: 'options',
|
||||
noDataExpression: true,
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['form'],
|
||||
},
|
||||
},
|
||||
options: [
|
||||
{
|
||||
name: 'Get',
|
||||
value: 'get',
|
||||
description: 'Get a form',
|
||||
action: 'Get a form',
|
||||
},
|
||||
{
|
||||
name: 'Get Many',
|
||||
value: 'getAll',
|
||||
description: 'Get many forms',
|
||||
action: 'Get many forms',
|
||||
},
|
||||
{
|
||||
name: 'Redeploy',
|
||||
value: 'redeploy',
|
||||
description: 'Redeploy Current Form Version',
|
||||
action: 'Redeploy Current Form Version',
|
||||
},
|
||||
],
|
||||
default: 'get',
|
||||
},
|
||||
];
|
||||
|
||||
export const formFields: INodeProperties[] = [
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* form:get */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
{
|
||||
displayName: 'Form Name or ID',
|
||||
name: 'formId',
|
||||
type: 'options',
|
||||
typeOptions: {
|
||||
loadOptionsMethod: 'loadForms',
|
||||
},
|
||||
required: true,
|
||||
default: '',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['form'],
|
||||
operation: ['get', 'redeploy'],
|
||||
},
|
||||
},
|
||||
description:
|
||||
'Form ID (e.g. aSAvYreNzVEkrWg5Gdcvg). Choose from the list, or specify an ID using an <a href="https://docs.n8n.io/code/expressions/">expression</a>.',
|
||||
},
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* form:getAll */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
{
|
||||
displayName: 'Return All',
|
||||
name: 'returnAll',
|
||||
type: 'boolean',
|
||||
required: true,
|
||||
default: false,
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['form'],
|
||||
operation: ['getAll'],
|
||||
},
|
||||
},
|
||||
description: 'Whether to return all results or only up to a given limit',
|
||||
},
|
||||
{
|
||||
displayName: 'Limit',
|
||||
name: 'limit',
|
||||
type: 'number',
|
||||
typeOptions: {
|
||||
maxValue: 3000,
|
||||
},
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['form'],
|
||||
operation: ['getAll'],
|
||||
returnAll: [false],
|
||||
},
|
||||
},
|
||||
default: 1000,
|
||||
description: 'Max number of results to return',
|
||||
},
|
||||
{
|
||||
displayName: 'Options',
|
||||
name: 'options',
|
||||
placeholder: 'Add option',
|
||||
type: 'collection',
|
||||
default: {},
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['form'],
|
||||
operation: ['getAll'],
|
||||
},
|
||||
},
|
||||
options: [
|
||||
{
|
||||
displayName: 'Sort',
|
||||
name: 'sort',
|
||||
type: 'fixedCollection',
|
||||
typeOptions: {
|
||||
multipleValues: false,
|
||||
},
|
||||
default: {},
|
||||
placeholder: 'Add Sort',
|
||||
options: [
|
||||
{
|
||||
displayName: 'Sort',
|
||||
name: 'value',
|
||||
values: [
|
||||
{
|
||||
displayName: 'Descending',
|
||||
name: 'descending',
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
description: 'Whether to sort by descending order',
|
||||
},
|
||||
{
|
||||
displayName: 'Order By',
|
||||
name: 'ordering',
|
||||
type: 'options',
|
||||
default: 'date_modified',
|
||||
options: [
|
||||
{
|
||||
name: 'Asset Type',
|
||||
value: 'asset_type',
|
||||
},
|
||||
{
|
||||
name: 'Date Modified',
|
||||
value: 'date_modified',
|
||||
},
|
||||
{
|
||||
name: 'Name',
|
||||
value: 'name',
|
||||
},
|
||||
{
|
||||
name: 'Owner Username',
|
||||
value: 'owner__username',
|
||||
},
|
||||
{
|
||||
name: 'Subscribers Count',
|
||||
value: 'subscribers_count',
|
||||
},
|
||||
],
|
||||
description: 'Field to order by',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
displayName: 'Filters',
|
||||
name: 'filters',
|
||||
type: 'collection',
|
||||
placeholder: 'Add Filter',
|
||||
default: {},
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['form'],
|
||||
operation: ['getAll'],
|
||||
},
|
||||
},
|
||||
options: [
|
||||
{
|
||||
displayName: 'Filter',
|
||||
name: 'filter',
|
||||
type: 'string',
|
||||
default: 'asset_type:survey',
|
||||
description:
|
||||
'A text search query based on form data - e.g. "owner__username:meg AND name__icontains:quixotic" - see <a href="https://github.com/kobotoolbox/kpi#searching" target="_blank">docs</a> for more details',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,375 @@
|
||||
import clone from 'lodash/clone';
|
||||
import compact from 'lodash/compact';
|
||||
import concat from 'lodash/concat';
|
||||
import escapeRegExp from 'lodash/escapeRegExp';
|
||||
import every from 'lodash/every';
|
||||
import first from 'lodash/first';
|
||||
import isArray from 'lodash/isArray';
|
||||
import isString from 'lodash/isString';
|
||||
import last from 'lodash/last';
|
||||
import set from 'lodash/set';
|
||||
import some from 'lodash/some';
|
||||
import split from 'lodash/split';
|
||||
import toNumber from 'lodash/toNumber';
|
||||
import toString from 'lodash/toString';
|
||||
import trim from 'lodash/trim';
|
||||
import type {
|
||||
IDataObject,
|
||||
IExecuteFunctions,
|
||||
ILoadOptionsFunctions,
|
||||
IHookFunctions,
|
||||
IHttpRequestOptions,
|
||||
INodeExecutionData,
|
||||
INodePropertyOptions,
|
||||
IWebhookFunctions,
|
||||
} from 'n8n-workflow';
|
||||
import { NodeOperationError } from 'n8n-workflow';
|
||||
|
||||
export async function koBoToolboxApiRequest(
|
||||
this: IExecuteFunctions | IWebhookFunctions | IHookFunctions | ILoadOptionsFunctions,
|
||||
option: IDataObject = {},
|
||||
): Promise<any> {
|
||||
const credentials = await this.getCredentials('koBoToolboxApi');
|
||||
|
||||
// Set up pagination / scrolling
|
||||
const returnAll = !!option.returnAll;
|
||||
if (returnAll) {
|
||||
// Override manual pagination options
|
||||
set(option, 'qs.limit', 3000);
|
||||
// Don't pass this custom param to helpers.httpRequest
|
||||
delete option.returnAll;
|
||||
}
|
||||
|
||||
const options: IHttpRequestOptions = {
|
||||
url: '',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
},
|
||||
json: true,
|
||||
};
|
||||
if (Object.keys(option)) {
|
||||
Object.assign(options, option);
|
||||
}
|
||||
if (options.url && !/^http(s)?:/.test(options.url)) {
|
||||
options.url = (credentials.URL as string) + options.url;
|
||||
}
|
||||
|
||||
let results = null;
|
||||
let keepLooking = true;
|
||||
while (keepLooking) {
|
||||
const response = await this.helpers.httpRequestWithAuthentication.call(
|
||||
this,
|
||||
'koBoToolboxApi',
|
||||
options,
|
||||
);
|
||||
// Append or set results
|
||||
results = response.results ? concat(results || [], response.results) : response;
|
||||
if (returnAll && response.next) {
|
||||
options.url = response.next;
|
||||
} else {
|
||||
keepLooking = false;
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
export async function koBoToolboxRawRequest(
|
||||
this: IExecuteFunctions | IWebhookFunctions | IHookFunctions | ILoadOptionsFunctions,
|
||||
option: IHttpRequestOptions,
|
||||
): Promise<any> {
|
||||
const credentials = await this.getCredentials('koBoToolboxApi');
|
||||
|
||||
if (option.url && !/^http(s)?:/.test(option.url)) {
|
||||
option.url = (credentials.URL as string) + option.url;
|
||||
}
|
||||
|
||||
return await this.helpers.httpRequestWithAuthentication.call(this, 'koBoToolboxApi', option);
|
||||
}
|
||||
|
||||
function parseGeoPoint(geoPoint: string): null | number[] {
|
||||
// Check if it looks like a "lat lon z precision" flat string e.g. "-1.931161 30.079811 0 0" (lat, lon, elevation, precision)
|
||||
// NOTE: we are discarding the elevation and precision values since they're not (well) supported in GeoJSON
|
||||
const coordinates = split(geoPoint, ' ');
|
||||
if (
|
||||
coordinates.length >= 2 &&
|
||||
every(coordinates, (coord) => coord && /^-?\d+(?:\.\d+)?$/.test(toString(coord)))
|
||||
) {
|
||||
// NOTE: GeoJSON uses lon, lat, while most common systems use lat, lon order!
|
||||
return [toNumber(coordinates[1]), toNumber(coordinates[0])];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function parseStringList(value: string): string[] {
|
||||
return split(toString(value), /[\s,]+/);
|
||||
}
|
||||
|
||||
const matchWildcard = (value: string, pattern: string): boolean => {
|
||||
const regex = new RegExp(`^${escapeRegExp(pattern).replace('\\*', '.*')}$`);
|
||||
return regex.test(value);
|
||||
};
|
||||
|
||||
const formatValue = (value: any, format: string): any => {
|
||||
if (isString(value)) {
|
||||
// Sanitize value
|
||||
value = toString(value);
|
||||
|
||||
// Parse geoPoints
|
||||
const geoPoint = parseGeoPoint(value as string);
|
||||
if (geoPoint) {
|
||||
return {
|
||||
type: 'Point',
|
||||
coordinates: geoPoint,
|
||||
};
|
||||
}
|
||||
|
||||
// Check if it's a closed polygon geo-shape: -1.954117 30.085159 0 0;-1.955005 30.084622 0 0;-1.956057 30.08506 0 0;-1.956393 30.086229 0 0;-1.955853 30.087143 0 0;-1.954609 30.08725 0 0;-1.953966 30.086735 0 0;-1.953805 30.085897 0 0;-1.954117 30.085159 0 0
|
||||
const points = value.split(';');
|
||||
if (points.length >= 2 && /^[-\d\.\s;]+$/.test(value as string)) {
|
||||
// Using the GeoJSON format as per https://geojson.org/
|
||||
const coordinates = compact(points.map(parseGeoPoint) as number[]);
|
||||
// Only return if all values are properly parsed
|
||||
if (coordinates.length === points.length) {
|
||||
// If the shape is closed, declare it as Polygon, otherwise as LineString
|
||||
|
||||
if (first(points) === last(points)) {
|
||||
return {
|
||||
type: 'Polygon',
|
||||
coordinates: [coordinates],
|
||||
};
|
||||
}
|
||||
|
||||
return { type: 'LineString', coordinates };
|
||||
}
|
||||
}
|
||||
|
||||
// Parse numbers
|
||||
if ('number' === format) {
|
||||
return toNumber(value);
|
||||
}
|
||||
|
||||
// Split multi-select
|
||||
if ('multiSelect' === format) {
|
||||
return split(toString(value), ' ');
|
||||
}
|
||||
}
|
||||
|
||||
return value;
|
||||
};
|
||||
|
||||
export function formatSubmission(
|
||||
submission: IDataObject,
|
||||
selectMasks: string[] = [],
|
||||
numberMasks: string[] = [],
|
||||
): IDataObject {
|
||||
// Create a shallow copy of the submission
|
||||
const response = {} as IDataObject;
|
||||
|
||||
for (const key of Object.keys(submission)) {
|
||||
let value = clone(submission[key]);
|
||||
// Sanitize key names: split by group, trim _
|
||||
const sanitizedKey = key
|
||||
.split('/')
|
||||
.map((k) => trim(k, ' _'))
|
||||
.join('.');
|
||||
const leafKey = sanitizedKey.split('.').pop() || '';
|
||||
let format = 'string';
|
||||
if (some(numberMasks, (mask) => matchWildcard(leafKey, mask))) {
|
||||
format = 'number';
|
||||
}
|
||||
if (some(selectMasks, (mask) => matchWildcard(leafKey, mask))) {
|
||||
format = 'multiSelect';
|
||||
}
|
||||
|
||||
value = formatValue(value, format);
|
||||
|
||||
set(response, sanitizedKey, value);
|
||||
}
|
||||
|
||||
// Reformat _geolocation
|
||||
if (
|
||||
isArray(response.geolocation) &&
|
||||
response.geolocation.length === 2 &&
|
||||
response.geolocation[0] &&
|
||||
response.geolocation[1]
|
||||
) {
|
||||
response.geolocation = {
|
||||
type: 'Point',
|
||||
coordinates: [response.geolocation[1], response.geolocation[0]],
|
||||
};
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
const KOBO_DOMAINS = ['kobotoolbox.org', 'humanitarianresponse.info'];
|
||||
|
||||
export function validateAttachmentUrl(url: string, allowedDomain: string): void {
|
||||
let attachmentUrl: URL;
|
||||
let allowedUrl: URL;
|
||||
|
||||
try {
|
||||
attachmentUrl = new URL(url);
|
||||
} catch {
|
||||
throw new Error('Invalid attachment URL format');
|
||||
}
|
||||
|
||||
if (!['http:', 'https:'].includes(attachmentUrl.protocol)) {
|
||||
throw new Error('Attachment URL must use HTTP or HTTPS');
|
||||
}
|
||||
|
||||
try {
|
||||
if (!allowedDomain.startsWith('http://') && !allowedDomain.startsWith('https://')) {
|
||||
allowedDomain = `https://${allowedDomain}`;
|
||||
}
|
||||
allowedUrl = new URL(allowedDomain);
|
||||
} catch {
|
||||
throw new Error('Invalid allowed domain configuration');
|
||||
}
|
||||
|
||||
const attachmentHost = attachmentUrl.hostname.toLowerCase();
|
||||
const allowedHost = allowedUrl.hostname.toLowerCase();
|
||||
|
||||
const matchesDomain =
|
||||
attachmentHost === allowedHost || attachmentHost.endsWith(`.${allowedHost}`);
|
||||
|
||||
if (matchesDomain) return;
|
||||
|
||||
const isKoboDomain = KOBO_DOMAINS.some(
|
||||
(domain) => allowedHost === domain || allowedHost.endsWith(`.${domain}`),
|
||||
);
|
||||
|
||||
if (isKoboDomain) {
|
||||
const isFromKoboDomain = KOBO_DOMAINS.some(
|
||||
(domain) => attachmentHost === domain || attachmentHost.endsWith(`.${domain}`),
|
||||
);
|
||||
|
||||
if (isFromKoboDomain) return;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Attachment URL domain (${attachmentHost}) does not match allowed domain (${allowedHost})`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function downloadAttachments(
|
||||
this: IExecuteFunctions | IWebhookFunctions,
|
||||
submission: IDataObject,
|
||||
options: IDataObject,
|
||||
): Promise<INodeExecutionData> {
|
||||
// Initialize return object with the original submission JSON content
|
||||
const binaryItem: INodeExecutionData = {
|
||||
json: {
|
||||
...submission,
|
||||
},
|
||||
binary: {},
|
||||
};
|
||||
|
||||
const credentials = await this.getCredentials('koBoToolboxApi');
|
||||
const koboToolboxUrl = credentials.URL as string;
|
||||
|
||||
// Look for attachment links - there can be more than one
|
||||
const attachmentList = (submission._attachments || submission.attachments) as any[];
|
||||
|
||||
if (attachmentList?.length) {
|
||||
for (const [index, attachment] of attachmentList.entries()) {
|
||||
// look for the question name linked to this attachment
|
||||
const fileName = attachment.filename;
|
||||
const sanitizedFileName = toString(fileName).replace(/_[^_]+(?=\.\w+)/, ''); // strip suffix
|
||||
|
||||
let relatedQuestion = null;
|
||||
if ('question' === options.binaryNamingScheme) {
|
||||
for (const question of Object.keys(submission)) {
|
||||
// The logic to map attachments to question is sometimes ambiguous:
|
||||
// - If the attachment is linked to a question, the question's value is the same as the attachment's filename (with spaces replaced by underscores)
|
||||
// - BUT sometimes the attachment's filename has an extra suffix, e.g. "My_Picture_0OdlaKJ.jpg", would map to the question "picture": "My Picture.jpg"
|
||||
const sanitizedQuestionValue = toString(submission[question]).replace(/\s/g, '_'); // replace spaces with underscores
|
||||
if (sanitizedFileName === sanitizedQuestionValue) {
|
||||
relatedQuestion = question;
|
||||
// Just use the first match...
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Download attachment
|
||||
// NOTE: this needs to follow redirects (possibly across domains), while keeping Authorization headers
|
||||
// The Axios client will not propagate the Authorization header on redirects (see https://github.com/axios/axios/issues/3607), so we need to follow ourselves...
|
||||
let response = null;
|
||||
const attachmentUrl =
|
||||
attachment[options.version as string] || (attachment.download_url as string);
|
||||
|
||||
try {
|
||||
validateAttachmentUrl(attachmentUrl, koboToolboxUrl);
|
||||
} catch (error) {
|
||||
throw new NodeOperationError(this.getNode(), error.message);
|
||||
}
|
||||
|
||||
let final = false,
|
||||
redir = 0;
|
||||
|
||||
const axiosOptions: IHttpRequestOptions = {
|
||||
url: attachmentUrl,
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Token ${credentials.token}`,
|
||||
},
|
||||
ignoreHttpStatusErrors: true,
|
||||
returnFullResponse: true,
|
||||
disableFollowRedirect: true,
|
||||
encoding: 'arraybuffer',
|
||||
};
|
||||
|
||||
while (!final && redir < 5) {
|
||||
response = await this.helpers.httpRequest(axiosOptions);
|
||||
|
||||
if (response?.headers.location) {
|
||||
try {
|
||||
validateAttachmentUrl(response.headers.location, koboToolboxUrl);
|
||||
} catch (error) {
|
||||
throw new NodeOperationError(this.getNode(), error.message);
|
||||
}
|
||||
// Follow redirect
|
||||
axiosOptions.url = response.headers.location;
|
||||
redir++;
|
||||
} else {
|
||||
final = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (response?.body) {
|
||||
// Use the provided prefix if any, otherwise try to use the original question name
|
||||
let binaryName;
|
||||
if ('question' === options.binaryNamingScheme && relatedQuestion) {
|
||||
binaryName = relatedQuestion;
|
||||
} else {
|
||||
binaryName = `${options.dataPropertyAttachmentsPrefixName || 'attachment_'}${index}`;
|
||||
}
|
||||
|
||||
binaryItem.binary![binaryName] = await this.helpers.prepareBinaryData(
|
||||
response.body as Buffer,
|
||||
fileName as string,
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
delete binaryItem.binary;
|
||||
}
|
||||
|
||||
// Add item to final output - even if there's no attachment retrieved
|
||||
return binaryItem;
|
||||
}
|
||||
|
||||
export async function loadForms(this: ILoadOptionsFunctions): Promise<INodePropertyOptions[]> {
|
||||
const responseData = await koBoToolboxApiRequest.call(this, {
|
||||
url: '/api/v2/assets/',
|
||||
qs: {
|
||||
q: 'asset_type:survey',
|
||||
ordering: 'name',
|
||||
},
|
||||
scroll: true,
|
||||
});
|
||||
|
||||
return responseData?.map((survey: any) => ({ name: survey.name, value: survey.uid })) || [];
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import type { INodeProperties } from 'n8n-workflow';
|
||||
|
||||
export const hookOperations: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Operation',
|
||||
name: 'operation',
|
||||
type: 'options',
|
||||
noDataExpression: true,
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['hook'],
|
||||
},
|
||||
},
|
||||
options: [
|
||||
{
|
||||
name: 'Get',
|
||||
value: 'get',
|
||||
description: 'Get a single hook definition',
|
||||
action: 'Get a hook',
|
||||
},
|
||||
{
|
||||
name: 'Get Many',
|
||||
value: 'getAll',
|
||||
description: 'List many hooks on a form',
|
||||
action: 'Get Many hooks',
|
||||
},
|
||||
{
|
||||
name: 'Logs',
|
||||
value: 'getLogs',
|
||||
description: 'Get hook logs',
|
||||
action: 'Get Logs for a hook',
|
||||
},
|
||||
{
|
||||
name: 'Retry All',
|
||||
value: 'retryAll',
|
||||
description: 'Retry all failed attempts for a given hook',
|
||||
action: 'Retry All hooks',
|
||||
},
|
||||
{
|
||||
name: 'Retry One',
|
||||
value: 'retryOne',
|
||||
description: 'Retry a specific hook',
|
||||
action: 'Retry One hook',
|
||||
},
|
||||
],
|
||||
default: 'getAll',
|
||||
},
|
||||
];
|
||||
|
||||
export const hookFields: INodeProperties[] = [
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* hook:get */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
{
|
||||
displayName: 'Form Name or ID',
|
||||
name: 'formId',
|
||||
type: 'options',
|
||||
typeOptions: {
|
||||
loadOptionsMethod: 'loadForms',
|
||||
},
|
||||
required: true,
|
||||
default: '',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['hook'],
|
||||
operation: ['get', 'retryOne', 'retryAll', 'getLogs', 'getAll'],
|
||||
},
|
||||
},
|
||||
description:
|
||||
'Form ID (e.g. aSAvYreNzVEkrWg5Gdcvg). Choose from the list, or specify an ID using an <a href="https://docs.n8n.io/code/expressions/">expression</a>.',
|
||||
},
|
||||
{
|
||||
displayName: 'Hook ID',
|
||||
name: 'hookId',
|
||||
type: 'string',
|
||||
required: true,
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['hook'],
|
||||
operation: ['get', 'retryOne', 'retryAll', 'getLogs'],
|
||||
},
|
||||
},
|
||||
default: '',
|
||||
description: 'Hook ID (starts with h, e.g. hVehywQ2oXPYGHJHKtqth4)',
|
||||
},
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* hook:getAll */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
{
|
||||
displayName: 'Hook Log ID',
|
||||
name: 'logId',
|
||||
type: 'string',
|
||||
required: true,
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['hook'],
|
||||
operation: ['retryOne'],
|
||||
},
|
||||
},
|
||||
default: '',
|
||||
description: 'Hook log ID (starts with hl, e.g. hlSbGKaUKzTVNoWEVMYbLHe)',
|
||||
},
|
||||
{
|
||||
displayName: 'Return All',
|
||||
name: 'returnAll',
|
||||
type: 'boolean',
|
||||
required: true,
|
||||
default: false,
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['hook'],
|
||||
operation: ['getAll'],
|
||||
},
|
||||
},
|
||||
description: 'Whether to return all results or only up to a given limit',
|
||||
},
|
||||
{
|
||||
displayName: 'Limit',
|
||||
name: 'limit',
|
||||
type: 'number',
|
||||
typeOptions: {
|
||||
maxValue: 3000,
|
||||
},
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['hook'],
|
||||
operation: ['getAll'],
|
||||
returnAll: [false],
|
||||
},
|
||||
},
|
||||
default: 1000,
|
||||
description: 'Max number of results to return',
|
||||
},
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* hook:getLogs */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
{
|
||||
displayName: 'Log Status',
|
||||
name: 'status',
|
||||
type: 'options',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['hook'],
|
||||
operation: ['getLogs'],
|
||||
},
|
||||
},
|
||||
default: '',
|
||||
description: 'Only retrieve logs with a specific status',
|
||||
options: [
|
||||
{
|
||||
name: 'All',
|
||||
value: '',
|
||||
},
|
||||
{
|
||||
name: 'Failed',
|
||||
value: '0',
|
||||
},
|
||||
{
|
||||
name: 'Pending',
|
||||
value: '1',
|
||||
},
|
||||
{
|
||||
name: 'Success',
|
||||
value: '2',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
displayName: 'Start Date',
|
||||
name: 'startDate',
|
||||
type: 'dateTime',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['hook'],
|
||||
operation: ['getLogs'],
|
||||
},
|
||||
},
|
||||
default: '',
|
||||
description: 'Minimum date for the hook log to retrieve',
|
||||
},
|
||||
{
|
||||
displayName: 'End Date',
|
||||
name: 'endDate',
|
||||
type: 'dateTime',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['hook'],
|
||||
operation: ['getLogs'],
|
||||
},
|
||||
},
|
||||
default: '',
|
||||
description: 'Maximum date for the hook log to retrieve',
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"node": "n8n-nodes-base.koBoToolbox",
|
||||
"nodeVersion": "1.0",
|
||||
"codexVersion": "1.0",
|
||||
"categories": ["Communication", "Data & Storage"],
|
||||
"resources": {
|
||||
"credentialDocumentation": [
|
||||
{
|
||||
"url": "https://docs.n8n.io/integrations/builtin/credentials/kobotoolbox/"
|
||||
}
|
||||
],
|
||||
"primaryDocumentation": [
|
||||
{
|
||||
"url": "https://docs.n8n.io/integrations/builtin/app-nodes/n8n-nodes-base.kobotoolbox/"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,479 @@
|
||||
import type {
|
||||
IExecuteFunctions,
|
||||
IBinaryKeyData,
|
||||
IDataObject,
|
||||
INodeExecutionData,
|
||||
INodeType,
|
||||
INodeTypeDescription,
|
||||
} from 'n8n-workflow';
|
||||
import { NodeConnectionTypes } from 'n8n-workflow';
|
||||
|
||||
import { fileFields, fileOperations } from './FileDescription';
|
||||
import { formFields, formOperations } from './FormDescription';
|
||||
import {
|
||||
downloadAttachments,
|
||||
formatSubmission,
|
||||
koBoToolboxApiRequest,
|
||||
koBoToolboxRawRequest,
|
||||
loadForms,
|
||||
parseStringList,
|
||||
} from './GenericFunctions';
|
||||
import { hookFields, hookOperations } from './HookDescription';
|
||||
import { submissionFields, submissionOperations } from './SubmissionDescription';
|
||||
|
||||
export class KoBoToolbox implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
displayName: 'KoBoToolbox',
|
||||
name: 'koBoToolbox',
|
||||
icon: 'file:koBoToolbox.svg',
|
||||
group: ['transform'],
|
||||
version: 1,
|
||||
description: 'Work with KoBoToolbox forms and submissions',
|
||||
subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}',
|
||||
defaults: {
|
||||
name: 'KoBoToolbox',
|
||||
},
|
||||
usableAsTool: true,
|
||||
inputs: [NodeConnectionTypes.Main],
|
||||
outputs: [NodeConnectionTypes.Main],
|
||||
credentials: [
|
||||
{
|
||||
name: 'koBoToolboxApi',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
properties: [
|
||||
{
|
||||
displayName: 'Resource',
|
||||
name: 'resource',
|
||||
type: 'options',
|
||||
noDataExpression: true,
|
||||
options: [
|
||||
{
|
||||
name: 'File',
|
||||
value: 'file',
|
||||
},
|
||||
{
|
||||
name: 'Form',
|
||||
value: 'form',
|
||||
},
|
||||
{
|
||||
name: 'Hook',
|
||||
value: 'hook',
|
||||
},
|
||||
{
|
||||
name: 'Submission',
|
||||
value: 'submission',
|
||||
},
|
||||
],
|
||||
default: 'submission',
|
||||
required: true,
|
||||
},
|
||||
...formOperations,
|
||||
...formFields,
|
||||
...hookOperations,
|
||||
...hookFields,
|
||||
...submissionOperations,
|
||||
...submissionFields,
|
||||
...fileOperations,
|
||||
...fileFields,
|
||||
],
|
||||
};
|
||||
|
||||
methods = {
|
||||
loadOptions: {
|
||||
loadForms,
|
||||
},
|
||||
};
|
||||
|
||||
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
|
||||
let responseData: any;
|
||||
|
||||
let returnData: any[] = [];
|
||||
const binaryItems: INodeExecutionData[] = [];
|
||||
const items = this.getInputData();
|
||||
const resource = this.getNodeParameter('resource', 0);
|
||||
const operation = this.getNodeParameter('operation', 0);
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
if (resource === 'form') {
|
||||
// *********************************************************************
|
||||
// Form
|
||||
// *********************************************************************
|
||||
|
||||
if (operation === 'get') {
|
||||
// ----------------------------------
|
||||
// Form: get
|
||||
// ----------------------------------
|
||||
const formId = this.getNodeParameter('formId', i) as string;
|
||||
responseData = [
|
||||
await koBoToolboxApiRequest.call(this, {
|
||||
url: `/api/v2/assets/${formId}`,
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
if (operation === 'getAll') {
|
||||
// ----------------------------------
|
||||
// Form: getAll
|
||||
// ----------------------------------
|
||||
const formQueryOptions = this.getNodeParameter('options', i) as {
|
||||
sort: {
|
||||
value: {
|
||||
descending: boolean;
|
||||
ordering: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
const formFilterOptions = this.getNodeParameter('filters', i);
|
||||
|
||||
responseData = await koBoToolboxApiRequest.call(this, {
|
||||
url: '/api/v2/assets/',
|
||||
qs: {
|
||||
limit: this.getNodeParameter('limit', i, 1000),
|
||||
...(formFilterOptions.filter && { q: formFilterOptions.filter }),
|
||||
...(formQueryOptions?.sort?.value?.ordering && {
|
||||
ordering:
|
||||
(formQueryOptions?.sort?.value?.descending ? '-' : '') +
|
||||
formQueryOptions?.sort?.value?.ordering,
|
||||
}),
|
||||
},
|
||||
scroll: this.getNodeParameter('returnAll', i),
|
||||
});
|
||||
}
|
||||
|
||||
if (operation === 'redeploy') {
|
||||
// ----------------------------------
|
||||
// Form: redeploy
|
||||
// ----------------------------------
|
||||
const formId = this.getNodeParameter('formId', i) as string;
|
||||
responseData = [
|
||||
await koBoToolboxApiRequest.call(this, {
|
||||
method: 'PATCH',
|
||||
url: `/api/v2/assets/${formId}/deployment/`,
|
||||
}),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
if (resource === 'submission') {
|
||||
// *********************************************************************
|
||||
// Submissions
|
||||
// *********************************************************************
|
||||
const formId = this.getNodeParameter('formId', i) as string;
|
||||
|
||||
if (operation === 'getAll') {
|
||||
// ----------------------------------
|
||||
// Submissions: getAll
|
||||
// ----------------------------------
|
||||
|
||||
const submissionQueryOptions = this.getNodeParameter('options', i);
|
||||
const filterJson = this.getNodeParameter('filterJson', i, null) as string;
|
||||
|
||||
responseData = await koBoToolboxApiRequest.call(this, {
|
||||
url: `/api/v2/assets/${formId}/data/`,
|
||||
qs: {
|
||||
limit: this.getNodeParameter('limit', i, 1000),
|
||||
...(filterJson && { query: filterJson }),
|
||||
...(submissionQueryOptions.sort && { sort: submissionQueryOptions.sort }),
|
||||
...(submissionQueryOptions.fields && {
|
||||
fields: JSON.stringify(parseStringList(submissionQueryOptions.fields as string)),
|
||||
}),
|
||||
},
|
||||
scroll: this.getNodeParameter('returnAll', i),
|
||||
});
|
||||
|
||||
if (submissionQueryOptions.reformat) {
|
||||
responseData = responseData.map((submission: IDataObject) => {
|
||||
return formatSubmission(
|
||||
submission,
|
||||
parseStringList(submissionQueryOptions.selectMask as string),
|
||||
parseStringList(submissionQueryOptions.numberMask as string),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if (submissionQueryOptions.download) {
|
||||
// Download related attachments
|
||||
for (const submission of responseData) {
|
||||
binaryItems.push(
|
||||
await downloadAttachments.call(
|
||||
this,
|
||||
submission as IDataObject,
|
||||
submissionQueryOptions,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (operation === 'get') {
|
||||
// ----------------------------------
|
||||
// Submissions: get
|
||||
// ----------------------------------
|
||||
const submissionId = this.getNodeParameter('submissionId', i) as string;
|
||||
const options = this.getNodeParameter('options', i);
|
||||
|
||||
responseData = [
|
||||
await koBoToolboxApiRequest.call(this, {
|
||||
url: `/api/v2/assets/${formId}/data/${submissionId}`,
|
||||
qs: {
|
||||
...(options.fields && {
|
||||
fields: JSON.stringify(parseStringList(options.fields as string)),
|
||||
}),
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
if (options.reformat) {
|
||||
responseData = responseData.map((submission: IDataObject) => {
|
||||
return formatSubmission(
|
||||
submission,
|
||||
parseStringList(options.selectMask as string),
|
||||
parseStringList(options.numberMask as string),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if (options.download) {
|
||||
// Download related attachments
|
||||
for (const submission of responseData) {
|
||||
binaryItems.push(
|
||||
await downloadAttachments.call(this, submission as IDataObject, options),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (operation === 'delete') {
|
||||
// ----------------------------------
|
||||
// Submissions: delete
|
||||
// ----------------------------------
|
||||
const id = this.getNodeParameter('submissionId', i) as string;
|
||||
|
||||
await koBoToolboxApiRequest.call(this, {
|
||||
method: 'DELETE',
|
||||
url: `/api/v2/assets/${formId}/data/${id}`,
|
||||
});
|
||||
|
||||
responseData = [
|
||||
{
|
||||
success: true,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (operation === 'getValidation') {
|
||||
// ----------------------------------
|
||||
// Submissions: getValidation
|
||||
// ----------------------------------
|
||||
const submissionId = this.getNodeParameter('submissionId', i) as string;
|
||||
|
||||
responseData = [
|
||||
await koBoToolboxApiRequest.call(this, {
|
||||
url: `/api/v2/assets/${formId}/data/${submissionId}/validation_status/`,
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
if (operation === 'setValidation') {
|
||||
// ----------------------------------
|
||||
// Submissions: setValidation
|
||||
// ----------------------------------
|
||||
const submissionId = this.getNodeParameter('submissionId', i) as string;
|
||||
const status = this.getNodeParameter('validationStatus', i) as string;
|
||||
|
||||
responseData = [
|
||||
await koBoToolboxApiRequest.call(this, {
|
||||
method: 'PATCH',
|
||||
url: `/api/v2/assets/${formId}/data/${submissionId}/validation_status/`,
|
||||
body: {
|
||||
'validation_status.uid': status,
|
||||
},
|
||||
}),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
if (resource === 'hook') {
|
||||
const formId = this.getNodeParameter('formId', i) as string;
|
||||
// *********************************************************************
|
||||
// Hook
|
||||
// *********************************************************************
|
||||
|
||||
if (operation === 'getAll') {
|
||||
// ----------------------------------
|
||||
// Hook: getAll
|
||||
// ----------------------------------
|
||||
responseData = await koBoToolboxApiRequest.call(this, {
|
||||
url: `/api/v2/assets/${formId}/hooks/`,
|
||||
qs: {
|
||||
limit: this.getNodeParameter('limit', i, 1000),
|
||||
},
|
||||
scroll: this.getNodeParameter('returnAll', i),
|
||||
});
|
||||
}
|
||||
|
||||
if (operation === 'get') {
|
||||
// ----------------------------------
|
||||
// Hook: get
|
||||
// ----------------------------------
|
||||
const hookId = this.getNodeParameter('hookId', i) as string;
|
||||
responseData = [
|
||||
await koBoToolboxApiRequest.call(this, {
|
||||
url: `/api/v2/assets/${formId}/hooks/${hookId}`,
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
if (operation === 'retryAll') {
|
||||
// ----------------------------------
|
||||
// Hook: retryAll
|
||||
// ----------------------------------
|
||||
const hookId = this.getNodeParameter('hookId', i) as string;
|
||||
responseData = [
|
||||
await koBoToolboxApiRequest.call(this, {
|
||||
method: 'PATCH',
|
||||
url: `/api/v2/assets/${formId}/hooks/${hookId}/retry/`,
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
if (operation === 'getLogs') {
|
||||
// ----------------------------------
|
||||
// Hook: getLogs
|
||||
// ----------------------------------
|
||||
const hookId = this.getNodeParameter('hookId', i) as string;
|
||||
const startDate = this.getNodeParameter('startDate', i, null);
|
||||
const endDate = this.getNodeParameter('endDate', i, null);
|
||||
const status = this.getNodeParameter('status', i, null);
|
||||
|
||||
responseData = await koBoToolboxApiRequest.call(this, {
|
||||
url: `/api/v2/assets/${formId}/hooks/${hookId}/logs/`,
|
||||
qs: {
|
||||
...(startDate && { start: startDate }),
|
||||
...(endDate && { end: endDate }),
|
||||
...(status && { status }),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (operation === 'retryOne') {
|
||||
// ----------------------------------
|
||||
// Hook: retryOne
|
||||
// ----------------------------------
|
||||
const hookId = this.getNodeParameter('hookId', i) as string;
|
||||
const logId = this.getNodeParameter('logId', i) as string;
|
||||
|
||||
responseData = [
|
||||
await koBoToolboxApiRequest.call(this, {
|
||||
method: 'PATCH',
|
||||
url: `/api/v2/assets/${formId}/hooks/${hookId}/logs/${logId}/retry/`,
|
||||
}),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
if (resource === 'file') {
|
||||
// *********************************************************************
|
||||
// File
|
||||
// *********************************************************************
|
||||
const formId = this.getNodeParameter('formId', i) as string;
|
||||
|
||||
if (operation === 'getAll') {
|
||||
responseData = [
|
||||
await koBoToolboxApiRequest.call(this, {
|
||||
url: `/api/v2/assets/${formId}/files`,
|
||||
qs: {
|
||||
file_type: 'form_media',
|
||||
},
|
||||
scroll: true,
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
if (operation === 'get') {
|
||||
const fileId = this.getNodeParameter('fileId', i) as string;
|
||||
const download = this.getNodeParameter('download', i);
|
||||
|
||||
responseData = [
|
||||
await koBoToolboxApiRequest.call(this, {
|
||||
url: `/api/v2/assets/${formId}/files/${fileId}`,
|
||||
}),
|
||||
];
|
||||
|
||||
if (responseData?.[0] && download) {
|
||||
const binaryPropertyName = this.getNodeParameter('binaryPropertyName', i);
|
||||
|
||||
const binaryItem: INodeExecutionData = {
|
||||
json: responseData[0],
|
||||
binary: {},
|
||||
};
|
||||
|
||||
const response = await koBoToolboxRawRequest.call(this, {
|
||||
url: `/api/v2/assets/${formId}/files/${fileId}/content`,
|
||||
encoding: 'arraybuffer',
|
||||
});
|
||||
|
||||
console.dir(response);
|
||||
|
||||
binaryItem.binary![binaryPropertyName] = await this.helpers.prepareBinaryData(
|
||||
response as Buffer,
|
||||
responseData[0].metadata.filename as string,
|
||||
);
|
||||
|
||||
binaryItems.push(binaryItem);
|
||||
}
|
||||
}
|
||||
|
||||
if (operation === 'delete') {
|
||||
const fileId = this.getNodeParameter('fileId', i) as string;
|
||||
responseData = [
|
||||
await koBoToolboxApiRequest.call(this, {
|
||||
method: 'DELETE',
|
||||
url: `/api/v2/assets/${formId}/files/${fileId}`,
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
if (operation === 'create') {
|
||||
const fileMode = this.getNodeParameter('fileMode', i) as string;
|
||||
const body: IDataObject = {
|
||||
description: 'Uploaded file',
|
||||
file_type: 'form_media',
|
||||
};
|
||||
|
||||
if ('binary' === fileMode) {
|
||||
const binaryPropertyName = this.getNodeParameter('binaryPropertyName', i);
|
||||
const item = items[i].binary as IBinaryKeyData;
|
||||
const binaryData = item[binaryPropertyName];
|
||||
|
||||
body.base64Encoded = 'data:' + binaryData.mimeType + ';base64,' + binaryData.data;
|
||||
body.metadata = {
|
||||
filename: binaryData.fileName,
|
||||
};
|
||||
} else {
|
||||
const fileUrl = this.getNodeParameter('fileUrl', i) as string;
|
||||
|
||||
body.metadata = {
|
||||
redirect_url: fileUrl,
|
||||
};
|
||||
}
|
||||
|
||||
responseData = [
|
||||
await koBoToolboxApiRequest.call(this, {
|
||||
method: 'POST',
|
||||
url: `/api/v2/assets/${formId}/files/`,
|
||||
body,
|
||||
}),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
returnData = returnData.concat(responseData);
|
||||
}
|
||||
|
||||
// Map data to n8n data
|
||||
return binaryItems.length > 0 ? [binaryItems] : [this.helpers.returnJsonArray(returnData)];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"node": "n8n-nodes-base.koBoToolboxTrigger",
|
||||
"nodeVersion": "1.0",
|
||||
"codexVersion": "1.0",
|
||||
"categories": ["Communication", "Data & Storage"],
|
||||
"resources": {
|
||||
"credentialDocumentation": [
|
||||
{
|
||||
"url": "https://docs.n8n.io/integrations/builtin/credentials/kobotoolbox/"
|
||||
}
|
||||
],
|
||||
"primaryDocumentation": [
|
||||
{
|
||||
"url": "https://docs.n8n.io/integrations/builtin/trigger-nodes/n8n-nodes-base.kobotoolboxtrigger/"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import {
|
||||
NodeConnectionTypes,
|
||||
type IDataObject,
|
||||
type IHookFunctions,
|
||||
type INodeType,
|
||||
type INodeTypeDescription,
|
||||
type IWebhookFunctions,
|
||||
type IWebhookResponseData,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import {
|
||||
downloadAttachments,
|
||||
formatSubmission,
|
||||
koBoToolboxApiRequest,
|
||||
loadForms,
|
||||
parseStringList,
|
||||
} from './GenericFunctions';
|
||||
import { options } from './Options';
|
||||
|
||||
export class KoBoToolboxTrigger implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
displayName: 'KoBoToolbox Trigger',
|
||||
name: 'koBoToolboxTrigger',
|
||||
icon: 'file:koBoToolbox.svg',
|
||||
group: ['trigger'],
|
||||
version: 1,
|
||||
description: 'Process KoBoToolbox submissions',
|
||||
defaults: {
|
||||
name: 'KoBoToolbox Trigger',
|
||||
},
|
||||
inputs: [],
|
||||
outputs: [NodeConnectionTypes.Main],
|
||||
credentials: [
|
||||
{
|
||||
name: 'koBoToolboxApi',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
webhooks: [
|
||||
{
|
||||
name: 'default',
|
||||
httpMethod: 'POST',
|
||||
responseMode: 'onReceived',
|
||||
path: 'webhook',
|
||||
},
|
||||
],
|
||||
properties: [
|
||||
{
|
||||
displayName: 'Form Name or ID',
|
||||
name: 'formId',
|
||||
type: 'options',
|
||||
typeOptions: {
|
||||
loadOptionsMethod: 'loadForms',
|
||||
},
|
||||
required: true,
|
||||
default: '',
|
||||
description:
|
||||
'Form ID (e.g. aSAvYreNzVEkrWg5Gdcvg). Choose from the list, or specify an ID using an <a href="https://docs.n8n.io/code/expressions/">expression</a>.',
|
||||
},
|
||||
{
|
||||
displayName: 'Trigger On',
|
||||
name: 'triggerOn',
|
||||
type: 'options',
|
||||
required: true,
|
||||
default: 'formSubmission',
|
||||
options: [
|
||||
{
|
||||
name: 'On Form Submission',
|
||||
value: 'formSubmission',
|
||||
},
|
||||
],
|
||||
},
|
||||
{ ...options },
|
||||
],
|
||||
};
|
||||
|
||||
webhookMethods = {
|
||||
default: {
|
||||
async checkExists(this: IHookFunctions): Promise<boolean> {
|
||||
const webhookData = this.getWorkflowStaticData('node');
|
||||
const webhookUrl = this.getNodeWebhookUrl('default');
|
||||
const formId = this.getNodeParameter('formId') as string;
|
||||
const webhooks = await koBoToolboxApiRequest.call(this, {
|
||||
url: `/api/v2/assets/${formId}/hooks/`,
|
||||
});
|
||||
for (const webhook of webhooks || []) {
|
||||
if (webhook.endpoint === webhookUrl && webhook.active === true) {
|
||||
webhookData.webhookId = webhook.uid;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
async create(this: IHookFunctions): Promise<boolean> {
|
||||
const webhookData = this.getWorkflowStaticData('node');
|
||||
const webhookUrl = this.getNodeWebhookUrl('default');
|
||||
const workflow = this.getWorkflow();
|
||||
const formId = this.getNodeParameter('formId') as string;
|
||||
|
||||
const response = await koBoToolboxApiRequest.call(this, {
|
||||
method: 'POST',
|
||||
url: `/api/v2/assets/${formId}/hooks/`,
|
||||
body: {
|
||||
name: `n8n webhook id ${workflow.id}: ${workflow.name}`,
|
||||
endpoint: webhookUrl,
|
||||
email_notification: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.uid) {
|
||||
webhookData.webhookId = response.uid;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
|
||||
async delete(this: IHookFunctions): Promise<boolean> {
|
||||
const webhookData = this.getWorkflowStaticData('node');
|
||||
const formId = this.getNodeParameter('formId') as string;
|
||||
try {
|
||||
await koBoToolboxApiRequest.call(this, {
|
||||
method: 'DELETE',
|
||||
url: `/api/v2/assets/${formId}/hooks/${webhookData.webhookId}`,
|
||||
});
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
delete webhookData.webhookId;
|
||||
return true;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
methods = {
|
||||
loadOptions: {
|
||||
loadForms,
|
||||
},
|
||||
};
|
||||
|
||||
async webhook(this: IWebhookFunctions): Promise<IWebhookResponseData> {
|
||||
const req = this.getRequestObject();
|
||||
const formatOptions = this.getNodeParameter('formatOptions') as IDataObject;
|
||||
|
||||
const responseData = formatOptions.reformat
|
||||
? formatSubmission(
|
||||
req.body as IDataObject,
|
||||
parseStringList(formatOptions.selectMask as string),
|
||||
parseStringList(formatOptions.numberMask as string),
|
||||
)
|
||||
: req.body;
|
||||
|
||||
if (formatOptions.download) {
|
||||
// Download related attachments
|
||||
return {
|
||||
workflowData: [
|
||||
[await downloadAttachments.call(this, responseData as IDataObject, formatOptions)],
|
||||
],
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
workflowData: [this.helpers.returnJsonArray([responseData])],
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import type { INodeProperties } from 'n8n-workflow';
|
||||
|
||||
export const options = {
|
||||
displayName: 'Options',
|
||||
placeholder: 'Add option',
|
||||
name: 'formatOptions',
|
||||
type: 'collection',
|
||||
default: {},
|
||||
options: [
|
||||
{
|
||||
displayName: 'Download Attachments',
|
||||
name: 'download',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: 'Whether to download submitted attachments',
|
||||
},
|
||||
{
|
||||
displayName: 'Attachments Naming Scheme',
|
||||
name: 'binaryNamingScheme',
|
||||
type: 'options',
|
||||
default: 'sequence',
|
||||
displayOptions: {
|
||||
show: {
|
||||
download: [true],
|
||||
},
|
||||
},
|
||||
options: [
|
||||
{
|
||||
name: 'Sequence (e.g. attachment_N)',
|
||||
value: 'sequence',
|
||||
},
|
||||
{
|
||||
name: 'Use Original Form Question ID',
|
||||
value: 'question',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
displayName: 'Attachments Prefix',
|
||||
name: 'dataPropertyAttachmentsPrefixName',
|
||||
type: 'string',
|
||||
displayOptions: {
|
||||
show: {
|
||||
download: [true],
|
||||
binaryNamingScheme: ['sequence'],
|
||||
},
|
||||
},
|
||||
default: 'attachment_',
|
||||
description:
|
||||
'Prefix for name of the binary property to which to write the attachments. An index starting with 0 will be added. So if name is "attachment_" the first attachment is saved to "attachment_0"',
|
||||
},
|
||||
{
|
||||
displayName: 'File Size',
|
||||
name: 'version',
|
||||
type: 'options',
|
||||
displayOptions: {
|
||||
show: {
|
||||
download: [true],
|
||||
},
|
||||
},
|
||||
default: 'download_url',
|
||||
description: 'Attachment size to retrieve, if multiple versions are available',
|
||||
options: [
|
||||
{
|
||||
name: 'Original',
|
||||
value: 'download_url',
|
||||
},
|
||||
{
|
||||
name: 'Small',
|
||||
value: 'download_small_url',
|
||||
},
|
||||
{
|
||||
name: 'Medium',
|
||||
value: 'download_medium_url',
|
||||
},
|
||||
{
|
||||
name: 'Large',
|
||||
value: 'download_large_url',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
displayName: 'Multiselect Mask',
|
||||
name: 'selectMask',
|
||||
type: 'string',
|
||||
default: 'select_*',
|
||||
description:
|
||||
'Comma-separated list of wildcard-style selectors for fields that should be treated as multiselect fields, i.e. parsed as arrays.',
|
||||
},
|
||||
{
|
||||
displayName: 'Number Mask',
|
||||
name: 'numberMask',
|
||||
type: 'string',
|
||||
default: 'n_*, f_*',
|
||||
description:
|
||||
'Comma-separated list of wildcard-style selectors for fields that should be treated as numbers',
|
||||
},
|
||||
{
|
||||
displayName: 'Reformat',
|
||||
name: 'reformat',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description:
|
||||
'Whether to apply some reformatting to the submission data, such as parsing GeoJSON coordinates',
|
||||
},
|
||||
],
|
||||
} as INodeProperties;
|
||||
@@ -0,0 +1,323 @@
|
||||
import type { INodeProperties } from 'n8n-workflow';
|
||||
|
||||
export const submissionOperations: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Operation',
|
||||
name: 'operation',
|
||||
type: 'options',
|
||||
noDataExpression: true,
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['submission'],
|
||||
},
|
||||
},
|
||||
options: [
|
||||
{
|
||||
name: 'Delete',
|
||||
value: 'delete',
|
||||
description: 'Delete a single submission',
|
||||
action: 'Delete a submission',
|
||||
},
|
||||
{
|
||||
name: 'Get',
|
||||
value: 'get',
|
||||
description: 'Get a single submission',
|
||||
action: 'Get a submission',
|
||||
},
|
||||
{
|
||||
name: 'Get Many',
|
||||
value: 'getAll',
|
||||
description: 'Get many submissions',
|
||||
action: 'Get many submissions',
|
||||
},
|
||||
{
|
||||
name: 'Get Validation Status',
|
||||
value: 'getValidation',
|
||||
description: 'Get the validation status for the submission',
|
||||
action: 'Get the validation status for a submission',
|
||||
},
|
||||
{
|
||||
name: 'Update Validation Status',
|
||||
value: 'setValidation',
|
||||
description: 'Set the validation status of the submission',
|
||||
action: 'Update the validation status for a submission',
|
||||
},
|
||||
],
|
||||
default: 'getAll',
|
||||
},
|
||||
];
|
||||
|
||||
export const submissionFields: INodeProperties[] = [
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* submission:get */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
{
|
||||
displayName: 'Form Name or ID',
|
||||
name: 'formId',
|
||||
type: 'options',
|
||||
typeOptions: {
|
||||
loadOptionsMethod: 'loadForms',
|
||||
},
|
||||
required: true,
|
||||
default: '',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['submission'],
|
||||
operation: ['get', 'delete', 'getValidation', 'setValidation', 'getAll'],
|
||||
},
|
||||
},
|
||||
description:
|
||||
'Form ID (e.g. aSAvYreNzVEkrWg5Gdcvg). Choose from the list, or specify an ID using an <a href="https://docs.n8n.io/code/expressions/">expression</a>.',
|
||||
},
|
||||
{
|
||||
displayName: 'Submission ID',
|
||||
name: 'submissionId',
|
||||
type: 'string',
|
||||
required: true,
|
||||
default: '',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['submission'],
|
||||
operation: ['get', 'delete', 'getValidation', 'setValidation'],
|
||||
},
|
||||
},
|
||||
description: 'Submission ID (number, e.g. 245128)',
|
||||
},
|
||||
{
|
||||
displayName: 'Validation Status',
|
||||
name: 'validationStatus',
|
||||
type: 'options',
|
||||
required: true,
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['submission'],
|
||||
operation: ['setValidation'],
|
||||
},
|
||||
},
|
||||
default: '',
|
||||
options: [
|
||||
{
|
||||
name: 'Approved',
|
||||
value: 'validation_status_approved',
|
||||
},
|
||||
{
|
||||
name: 'Not Approved',
|
||||
value: 'validation_status_not_approved',
|
||||
},
|
||||
{
|
||||
name: 'On Hold',
|
||||
value: 'validation_status_on_hold',
|
||||
},
|
||||
],
|
||||
description: 'Desired Validation Status',
|
||||
},
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* submission:getAll */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
{
|
||||
displayName: 'Return All',
|
||||
name: 'returnAll',
|
||||
type: 'boolean',
|
||||
required: true,
|
||||
default: false,
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['submission'],
|
||||
operation: ['getAll'],
|
||||
},
|
||||
},
|
||||
description: 'Whether to return all results or only up to a given limit',
|
||||
},
|
||||
{
|
||||
displayName: 'Limit',
|
||||
name: 'limit',
|
||||
type: 'number',
|
||||
typeOptions: {
|
||||
maxValue: 3000,
|
||||
},
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['submission'],
|
||||
operation: ['getAll'],
|
||||
returnAll: [false],
|
||||
},
|
||||
},
|
||||
default: 100,
|
||||
description: 'Max number of results to return',
|
||||
},
|
||||
{
|
||||
displayName: 'Filter',
|
||||
name: 'filterType',
|
||||
type: 'options',
|
||||
default: 'none',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['submission'],
|
||||
operation: ['getAll'],
|
||||
},
|
||||
},
|
||||
options: [
|
||||
{
|
||||
name: 'None',
|
||||
value: 'none',
|
||||
},
|
||||
{
|
||||
name: 'JSON',
|
||||
value: 'json',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
displayName:
|
||||
'See <a href="https://github.com/SEL-Columbia/formhub/wiki/Formhub-Access-Points-(API)#api-parameters" target="_blank">Formhub API docs</a> to creating filters, using the MongoDB JSON format - e.g. {"_submission_time":{"$lt":"2021-10-01T01:02:03"}}',
|
||||
name: 'jsonNotice',
|
||||
type: 'notice',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['submission'],
|
||||
operation: ['getAll'],
|
||||
filterType: ['json'],
|
||||
},
|
||||
},
|
||||
default: '',
|
||||
},
|
||||
{
|
||||
displayName: 'Filters (JSON)',
|
||||
name: 'filterJson',
|
||||
type: 'string',
|
||||
default: '',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['submission'],
|
||||
operation: ['getAll'],
|
||||
filterType: ['json'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Options',
|
||||
name: 'options',
|
||||
type: 'collection',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['submission'],
|
||||
operation: ['get', 'getAll'],
|
||||
},
|
||||
},
|
||||
default: {},
|
||||
placeholder: 'Add option',
|
||||
options: [
|
||||
{
|
||||
displayName: 'Download Attachments',
|
||||
name: 'download',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: 'Whether to download submitted attachments',
|
||||
},
|
||||
{
|
||||
displayName: 'Attachments Naming Scheme',
|
||||
name: 'binaryNamingScheme',
|
||||
type: 'options',
|
||||
default: 'sequence',
|
||||
displayOptions: {
|
||||
show: {
|
||||
download: [true],
|
||||
},
|
||||
},
|
||||
options: [
|
||||
{
|
||||
name: 'Sequence (e.g. attachment_N)',
|
||||
value: 'sequence',
|
||||
},
|
||||
{
|
||||
name: 'Use Original Form Question ID',
|
||||
value: 'question',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
displayName: 'Attachments Prefix',
|
||||
name: 'dataPropertyAttachmentsPrefixName',
|
||||
type: 'string',
|
||||
displayOptions: {
|
||||
show: {
|
||||
download: [true],
|
||||
binaryNamingScheme: ['sequence'],
|
||||
},
|
||||
},
|
||||
default: 'attachment_',
|
||||
description:
|
||||
'Prefix for name of the binary property to which to write the attachments. An index starting with 0 will be added. So if name is "attachment_" the first attachment is saved to "attachment_0"',
|
||||
},
|
||||
{
|
||||
displayName: 'Fields to Retrieve',
|
||||
name: 'fields',
|
||||
type: 'string',
|
||||
default: '',
|
||||
description:
|
||||
'Comma-separated list of fields to retrieve (e.g. _submission_time,_submitted_by). If left blank, all fields are retrieved.',
|
||||
},
|
||||
{
|
||||
displayName: 'File Size',
|
||||
name: 'version',
|
||||
type: 'options',
|
||||
displayOptions: {
|
||||
show: {
|
||||
download: [true],
|
||||
},
|
||||
},
|
||||
default: 'download_url',
|
||||
description: 'Attachment size to retrieve, if multiple versions are available',
|
||||
options: [
|
||||
{
|
||||
name: 'Original',
|
||||
value: 'download_url',
|
||||
},
|
||||
{
|
||||
name: 'Small',
|
||||
value: 'download_small_url',
|
||||
},
|
||||
{
|
||||
name: 'Medium',
|
||||
value: 'download_medium_url',
|
||||
},
|
||||
{
|
||||
name: 'Large',
|
||||
value: 'download_large_url',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
displayName: 'Multiselect Mask',
|
||||
name: 'selectMask',
|
||||
type: 'string',
|
||||
default: 'select_*',
|
||||
description:
|
||||
'Comma-separated list of wildcard-style selectors for fields that should be treated as multiselect fields, i.e. parsed as arrays.',
|
||||
},
|
||||
{
|
||||
displayName: 'Number Mask',
|
||||
name: 'numberMask',
|
||||
type: 'string',
|
||||
default: 'n_*, f_*',
|
||||
description:
|
||||
'Comma-separated list of wildcard-style selectors for fields that should be treated as numbers',
|
||||
},
|
||||
{
|
||||
displayName: 'Reformat',
|
||||
name: 'reformat',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description:
|
||||
'Whether to apply some reformatting to the submission data, such as parsing GeoJSON coordinates',
|
||||
},
|
||||
{
|
||||
displayName: 'Sort',
|
||||
name: 'sort',
|
||||
type: 'json',
|
||||
default: '',
|
||||
description: 'Sort predicates, in MongoDB JSON format (e.g. {"_submission_time":1})',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"__version__": {
|
||||
"type": "string"
|
||||
},
|
||||
"_attachments": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"download_large_url": {
|
||||
"type": "string"
|
||||
},
|
||||
"download_medium_url": {
|
||||
"type": "string"
|
||||
},
|
||||
"download_small_url": {
|
||||
"type": "string"
|
||||
},
|
||||
"download_url": {
|
||||
"type": "string"
|
||||
},
|
||||
"filename": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"instance": {
|
||||
"type": "integer"
|
||||
},
|
||||
"mimetype": {
|
||||
"type": "string"
|
||||
},
|
||||
"question_xpath": {
|
||||
"type": "string"
|
||||
},
|
||||
"xform": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"_id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"_status": {
|
||||
"type": "string"
|
||||
},
|
||||
"_submission_time": {
|
||||
"type": "string"
|
||||
},
|
||||
"_uuid": {
|
||||
"type": "string"
|
||||
},
|
||||
"_validation_status": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"by_whom": {
|
||||
"type": "string"
|
||||
},
|
||||
"color": {
|
||||
"type": "string"
|
||||
},
|
||||
"label": {
|
||||
"type": "string"
|
||||
},
|
||||
"timestamp": {
|
||||
"type": "integer"
|
||||
},
|
||||
"uid": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"_xform_id_string": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"deviceid": {
|
||||
"type": "string"
|
||||
},
|
||||
"end": {
|
||||
"type": "string"
|
||||
},
|
||||
"formhub/uuid": {
|
||||
"type": "string"
|
||||
},
|
||||
"gps_note": {
|
||||
"type": "string"
|
||||
},
|
||||
"infra_categ": {
|
||||
"type": "string"
|
||||
},
|
||||
"infra_subtype": {
|
||||
"type": "string"
|
||||
},
|
||||
"location": {
|
||||
"type": "string"
|
||||
},
|
||||
"meta/instanceID": {
|
||||
"type": "string"
|
||||
},
|
||||
"personal_QR_code": {
|
||||
"type": "string"
|
||||
},
|
||||
"pic1": {
|
||||
"type": "string"
|
||||
},
|
||||
"pic2": {
|
||||
"type": "string"
|
||||
},
|
||||
"q2nd_pic": {
|
||||
"type": "string"
|
||||
},
|
||||
"start": {
|
||||
"type": "string"
|
||||
},
|
||||
"today": {
|
||||
"type": "string"
|
||||
},
|
||||
"username": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"version": 1
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="23.17 23.5 19.18 28.6"><style>.st79{fill:#64c0ff}</style><g id="Layer_4"><path d="M38.6 42.8v2.8c0 1.6-1.3 2.8-2.8 2.8h-6.1c-1.6 0-2.8-1.3-2.8-2.8V30c0-1.6 1.3-2.8 2.8-2.8h6.1c1.6 0 2.8 1.3 2.8 2.8v.4c.5-.2 1.1-.3 1.7-.3.7 0 1.4.1 2 .4V30c0-3.6-2.9-6.5-6.5-6.5h-6.1c-3.6 0-6.5 2.9-6.5 6.5v15.6c0 3.6 2.9 6.5 6.5 6.5h6.1c3.6 0 6.5-2.9 6.5-6.5v-7.1z" class="st79"/><path d="m35.6 41.9 6.6-7.6c.2-.2.2-.6-.1-.8-1.2-1-2.9-.9-3.9.3l-4.1 4.7c-.1.1-.3.1-.3 0l-1.5-1.9c-.3-.4-.8-.4-1.2 0-1 1-1 2.6-.1 3.7l1.2 1.5c.8 1.2 2.5 1.2 3.4.1" class="st79"/></g></svg>
|
||||
|
After Width: | Height: | Size: 600 B |
@@ -0,0 +1,631 @@
|
||||
import type { IExecuteFunctions, IDataObject } from 'n8n-workflow';
|
||||
import { NodeOperationError } from 'n8n-workflow';
|
||||
import { validateAttachmentUrl, downloadAttachments } from '../GenericFunctions';
|
||||
|
||||
describe('KoBoToolbox GenericFunctions', () => {
|
||||
describe('validateAttachmentUrl', () => {
|
||||
describe('valid URLs', () => {
|
||||
it('should accept URL with exact domain match', () => {
|
||||
expect(() => {
|
||||
validateAttachmentUrl('https://example.com/file.jpg', 'https://example.com');
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should accept URL with subdomain of allowed domain', () => {
|
||||
expect(() => {
|
||||
validateAttachmentUrl('https://sub.example.com/file.jpg', 'https://example.com');
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should accept URL with nested subdomain', () => {
|
||||
expect(() => {
|
||||
validateAttachmentUrl('https://deep.sub.example.com/file.jpg', 'https://example.com');
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should accept allowed domain without protocol', () => {
|
||||
expect(() => {
|
||||
validateAttachmentUrl('https://example.com/file.jpg', 'example.com');
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should accept HTTP URLs', () => {
|
||||
expect(() => {
|
||||
validateAttachmentUrl('http://example.com/file.jpg', 'http://example.com');
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should be case-insensitive for domain matching', () => {
|
||||
expect(() => {
|
||||
validateAttachmentUrl('https://EXAMPLE.COM/file.jpg', 'https://example.com');
|
||||
}).not.toThrow();
|
||||
|
||||
expect(() => {
|
||||
validateAttachmentUrl('https://example.com/file.jpg', 'https://EXAMPLE.COM');
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('KoBoToolbox special domains', () => {
|
||||
it('should accept kobotoolbox.org domain when allowed domain is kobotoolbox.org', () => {
|
||||
expect(() => {
|
||||
validateAttachmentUrl('https://kf.kobotoolbox.org/file.jpg', 'https://kobotoolbox.org');
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should accept humanitarianresponse.info domain when allowed domain is humanitarianresponse.info', () => {
|
||||
expect(() => {
|
||||
validateAttachmentUrl(
|
||||
'https://kf.humanitarianresponse.info/file.jpg',
|
||||
'https://humanitarianresponse.info',
|
||||
);
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should accept cross-domain between kobotoolbox.org subdomains', () => {
|
||||
expect(() => {
|
||||
validateAttachmentUrl(
|
||||
'https://kc.kobotoolbox.org/file.jpg',
|
||||
'https://kf.kobotoolbox.org',
|
||||
);
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should accept cross-domain between humanitarianresponse.info subdomains', () => {
|
||||
expect(() => {
|
||||
validateAttachmentUrl(
|
||||
'https://kc.humanitarianresponse.info/file.jpg',
|
||||
'https://kf.humanitarianresponse.info',
|
||||
);
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should accept attachment from kobotoolbox.org when allowed is humanitarianresponse.info', () => {
|
||||
expect(() => {
|
||||
validateAttachmentUrl(
|
||||
'https://kc.kobotoolbox.org/file.jpg',
|
||||
'https://kf.humanitarianresponse.info',
|
||||
);
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should accept attachment from humanitarianresponse.info when allowed is kobotoolbox.org', () => {
|
||||
expect(() => {
|
||||
validateAttachmentUrl(
|
||||
'https://kc.humanitarianresponse.info/file.jpg',
|
||||
'https://kf.kobotoolbox.org',
|
||||
);
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('invalid URLs', () => {
|
||||
it('should throw error for invalid attachment URL format', () => {
|
||||
expect(() => {
|
||||
validateAttachmentUrl('not-a-url', 'https://example.com');
|
||||
}).toThrow('Invalid attachment URL format');
|
||||
});
|
||||
|
||||
it('should throw error for non-HTTP(S) protocol', () => {
|
||||
expect(() => {
|
||||
validateAttachmentUrl('ftp://example.com/file.jpg', 'https://example.com');
|
||||
}).toThrow('Attachment URL must use HTTP or HTTPS');
|
||||
});
|
||||
|
||||
it('should throw error for file:// protocol', () => {
|
||||
expect(() => {
|
||||
validateAttachmentUrl('file:///etc/passwd', 'https://example.com');
|
||||
}).toThrow('Attachment URL must use HTTP or HTTPS');
|
||||
});
|
||||
|
||||
it('should throw error for javascript: protocol', () => {
|
||||
expect(() => {
|
||||
validateAttachmentUrl('javascript:alert(1)', 'https://example.com');
|
||||
}).toThrow('Attachment URL must use HTTP or HTTPS');
|
||||
});
|
||||
|
||||
it('should throw error for invalid allowed domain configuration', () => {
|
||||
expect(() => {
|
||||
validateAttachmentUrl('https://example.com/file.jpg', 'not a valid domain!!!');
|
||||
}).toThrow('Invalid allowed domain configuration');
|
||||
});
|
||||
|
||||
it('should throw error for mismatched domains', () => {
|
||||
expect(() => {
|
||||
validateAttachmentUrl('https://malicious.com/file.jpg', 'https://example.com');
|
||||
}).toThrow(
|
||||
'Attachment URL domain (malicious.com) does not match allowed domain (example.com)',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error for domain that looks like subdomain but is not', () => {
|
||||
expect(() => {
|
||||
validateAttachmentUrl('https://fakeexample.com/file.jpg', 'https://example.com');
|
||||
}).toThrow(
|
||||
'Attachment URL domain (fakeexample.com) does not match allowed domain (example.com)',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error when attachment is not from KoBoToolbox domains but allowed domain is', () => {
|
||||
expect(() => {
|
||||
validateAttachmentUrl('https://malicious.com/file.jpg', 'https://kf.kobotoolbox.org');
|
||||
}).toThrow(
|
||||
'Attachment URL domain (malicious.com) does not match allowed domain (kf.kobotoolbox.org)',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error for loopback IP address when allowed domain is public', () => {
|
||||
expect(() => {
|
||||
validateAttachmentUrl('http://127.0.0.1/file.jpg', 'https://example.com');
|
||||
}).toThrow('Attachment URL domain (127.0.0.1) does not match allowed domain (example.com)');
|
||||
});
|
||||
|
||||
it('should throw error for cloud metadata endpoint when allowed domain is public', () => {
|
||||
expect(() => {
|
||||
validateAttachmentUrl('http://169.254.169.254/file.jpg', 'https://example.com');
|
||||
}).toThrow(
|
||||
'Attachment URL domain (169.254.169.254) does not match allowed domain (example.com)',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error for localhost when allowed domain is public', () => {
|
||||
expect(() => {
|
||||
validateAttachmentUrl('http://localhost/file.jpg', 'https://example.com');
|
||||
}).toThrow('Attachment URL domain (localhost) does not match allowed domain (example.com)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle URLs with ports', () => {
|
||||
expect(() => {
|
||||
validateAttachmentUrl('https://example.com:8080/file.jpg', 'https://example.com:8080');
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should handle URLs with query parameters', () => {
|
||||
expect(() => {
|
||||
validateAttachmentUrl('https://example.com/file.jpg?token=abc123', 'https://example.com');
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should handle URLs with fragments', () => {
|
||||
expect(() => {
|
||||
validateAttachmentUrl('https://example.com/file.jpg#section', 'https://example.com');
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should handle URLs with paths', () => {
|
||||
expect(() => {
|
||||
validateAttachmentUrl('https://example.com/path/to/file.jpg', 'https://example.com');
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('downloadAttachments', () => {
|
||||
let mockExecuteFunctions: IExecuteFunctions;
|
||||
let mockGetCredentials: jest.Mock;
|
||||
let mockHttpRequest: jest.Mock;
|
||||
let mockPrepareBinaryData: jest.Mock;
|
||||
let mockGetNode: jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
mockGetCredentials = jest.fn();
|
||||
mockHttpRequest = jest.fn();
|
||||
mockPrepareBinaryData = jest.fn();
|
||||
mockGetNode = jest.fn();
|
||||
|
||||
mockExecuteFunctions = {
|
||||
getCredentials: mockGetCredentials,
|
||||
helpers: {
|
||||
httpRequest: mockHttpRequest,
|
||||
prepareBinaryData: mockPrepareBinaryData,
|
||||
},
|
||||
getNode: mockGetNode,
|
||||
} as unknown as IExecuteFunctions;
|
||||
|
||||
mockGetCredentials.mockResolvedValue({
|
||||
URL: 'https://kf.kobotoolbox.org',
|
||||
token: 'test-token-123',
|
||||
});
|
||||
|
||||
mockGetNode.mockReturnValue({ name: 'KoBoToolbox' });
|
||||
});
|
||||
|
||||
describe('successful downloads', () => {
|
||||
it('should download single attachment without redirect', async () => {
|
||||
const submission: IDataObject = {
|
||||
_id: 123,
|
||||
name: 'Test Submission',
|
||||
_attachments: [
|
||||
{
|
||||
filename: 'photo.jpg',
|
||||
download_url: 'https://kf.kobotoolbox.org/api/v2/assets/123/data/456/attachments/789',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const options: IDataObject = {
|
||||
version: 'download_url',
|
||||
binaryNamingScheme: 'index',
|
||||
dataPropertyAttachmentsPrefixName: 'attachment_',
|
||||
};
|
||||
|
||||
const mockBuffer = Buffer.from('fake-image-data');
|
||||
mockHttpRequest.mockResolvedValue({
|
||||
body: mockBuffer,
|
||||
headers: {},
|
||||
});
|
||||
|
||||
mockPrepareBinaryData.mockResolvedValue({
|
||||
data: 'base64-encoded-data',
|
||||
mimeType: 'image/jpeg',
|
||||
fileName: 'photo.jpg',
|
||||
});
|
||||
|
||||
const result = await downloadAttachments.call(mockExecuteFunctions, submission, options);
|
||||
|
||||
expect(result.json).toEqual(submission);
|
||||
expect(result.binary).toBeDefined();
|
||||
expect(result.binary!.attachment_0).toBeDefined();
|
||||
expect(mockHttpRequest).toHaveBeenCalledWith({
|
||||
url: 'https://kf.kobotoolbox.org/api/v2/assets/123/data/456/attachments/789',
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: 'Token test-token-123',
|
||||
},
|
||||
ignoreHttpStatusErrors: true,
|
||||
returnFullResponse: true,
|
||||
disableFollowRedirect: true,
|
||||
encoding: 'arraybuffer',
|
||||
});
|
||||
expect(mockPrepareBinaryData).toHaveBeenCalledWith(mockBuffer, 'photo.jpg');
|
||||
});
|
||||
|
||||
it('should download multiple attachments', async () => {
|
||||
const submission: IDataObject = {
|
||||
_id: 123,
|
||||
_attachments: [
|
||||
{
|
||||
filename: 'photo1.jpg',
|
||||
download_url: 'https://kf.kobotoolbox.org/attachment1',
|
||||
},
|
||||
{
|
||||
filename: 'photo2.jpg',
|
||||
download_url: 'https://kf.kobotoolbox.org/attachment2',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const options: IDataObject = {
|
||||
version: 'download_url',
|
||||
binaryNamingScheme: 'index',
|
||||
dataPropertyAttachmentsPrefixName: 'file_',
|
||||
};
|
||||
|
||||
mockHttpRequest.mockResolvedValue({
|
||||
body: Buffer.from('data'),
|
||||
headers: {},
|
||||
});
|
||||
|
||||
mockPrepareBinaryData.mockResolvedValue({
|
||||
data: 'base64-data',
|
||||
});
|
||||
|
||||
const result = await downloadAttachments.call(mockExecuteFunctions, submission, options);
|
||||
|
||||
expect(result.binary!.file_0).toBeDefined();
|
||||
expect(result.binary!.file_1).toBeDefined();
|
||||
expect(mockHttpRequest).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should follow redirects up to 5 times', async () => {
|
||||
const submission: IDataObject = {
|
||||
_id: 123,
|
||||
_attachments: [
|
||||
{
|
||||
filename: 'photo.jpg',
|
||||
download_url: 'https://kf.kobotoolbox.org/attachment',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const options: IDataObject = {
|
||||
version: 'download_url',
|
||||
binaryNamingScheme: 'index',
|
||||
dataPropertyAttachmentsPrefixName: 'attachment_',
|
||||
};
|
||||
|
||||
mockHttpRequest
|
||||
.mockResolvedValueOnce({
|
||||
headers: { location: 'https://kc.kobotoolbox.org/redirect1' },
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
headers: { location: 'https://kc.kobotoolbox.org/redirect2' },
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
body: Buffer.from('final-data'),
|
||||
headers: {},
|
||||
});
|
||||
|
||||
mockPrepareBinaryData.mockResolvedValue({ data: 'base64-data' });
|
||||
|
||||
const result = await downloadAttachments.call(mockExecuteFunctions, submission, options);
|
||||
|
||||
expect(result.binary!.attachment_0).toBeDefined();
|
||||
expect(mockHttpRequest).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('should use question naming scheme when configured', async () => {
|
||||
const submission: IDataObject = {
|
||||
_id: 123,
|
||||
photo_question: 'photo.jpg',
|
||||
_attachments: [
|
||||
{
|
||||
filename: 'photo.jpg',
|
||||
download_url: 'https://kf.kobotoolbox.org/attachment',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const options: IDataObject = {
|
||||
version: 'download_url',
|
||||
binaryNamingScheme: 'question',
|
||||
};
|
||||
|
||||
mockHttpRequest.mockResolvedValue({
|
||||
body: Buffer.from('data'),
|
||||
headers: {},
|
||||
});
|
||||
|
||||
mockPrepareBinaryData.mockResolvedValue({ data: 'base64-data' });
|
||||
|
||||
const result = await downloadAttachments.call(mockExecuteFunctions, submission, options);
|
||||
|
||||
expect(result.binary!.photo_question).toBeDefined();
|
||||
});
|
||||
|
||||
it('should handle attachment with suffix in filename', async () => {
|
||||
const submission: IDataObject = {
|
||||
_id: 123,
|
||||
picture: 'My Picture.jpg',
|
||||
_attachments: [
|
||||
{
|
||||
filename: 'My_Picture_0OdlaKJ.jpg',
|
||||
download_url: 'https://kf.kobotoolbox.org/attachment',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const options: IDataObject = {
|
||||
version: 'download_url',
|
||||
binaryNamingScheme: 'question',
|
||||
};
|
||||
|
||||
mockHttpRequest.mockResolvedValue({
|
||||
body: Buffer.from('data'),
|
||||
headers: {},
|
||||
});
|
||||
|
||||
mockPrepareBinaryData.mockResolvedValue({ data: 'base64-data' });
|
||||
|
||||
const result = await downloadAttachments.call(mockExecuteFunctions, submission, options);
|
||||
|
||||
expect(result.binary!.picture).toBeDefined();
|
||||
});
|
||||
|
||||
it('should handle submission with "attachments" field instead of "_attachments"', async () => {
|
||||
const submission: IDataObject = {
|
||||
_id: 123,
|
||||
attachments: [
|
||||
{
|
||||
filename: 'photo.jpg',
|
||||
download_url: 'https://kf.kobotoolbox.org/attachment',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const options: IDataObject = {
|
||||
version: 'download_url',
|
||||
binaryNamingScheme: 'index',
|
||||
dataPropertyAttachmentsPrefixName: 'attachment_',
|
||||
};
|
||||
|
||||
mockHttpRequest.mockResolvedValue({
|
||||
body: Buffer.from('data'),
|
||||
headers: {},
|
||||
});
|
||||
|
||||
mockPrepareBinaryData.mockResolvedValue({ data: 'base64-data' });
|
||||
|
||||
const result = await downloadAttachments.call(mockExecuteFunctions, submission, options);
|
||||
|
||||
expect(result.binary!.attachment_0).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('no attachments', () => {
|
||||
it('should return submission without binary when no attachments', async () => {
|
||||
const submission: IDataObject = {
|
||||
_id: 123,
|
||||
name: 'Test Submission',
|
||||
};
|
||||
|
||||
const options: IDataObject = {
|
||||
version: 'download_url',
|
||||
binaryNamingScheme: 'index',
|
||||
};
|
||||
|
||||
const result = await downloadAttachments.call(mockExecuteFunctions, submission, options);
|
||||
|
||||
expect(result.json).toEqual(submission);
|
||||
expect(result.binary).toBeUndefined();
|
||||
expect(mockHttpRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return submission without binary when attachments array is empty', async () => {
|
||||
const submission: IDataObject = {
|
||||
_id: 123,
|
||||
_attachments: [],
|
||||
};
|
||||
|
||||
const options: IDataObject = {
|
||||
version: 'download_url',
|
||||
binaryNamingScheme: 'index',
|
||||
};
|
||||
|
||||
const result = await downloadAttachments.call(mockExecuteFunctions, submission, options);
|
||||
|
||||
expect(result.json).toEqual(submission);
|
||||
expect(result.binary).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('should throw NodeOperationError for invalid attachment URL', async () => {
|
||||
const submission: IDataObject = {
|
||||
_id: 123,
|
||||
_attachments: [
|
||||
{
|
||||
filename: 'photo.jpg',
|
||||
download_url: 'https://malicious.com/file.jpg',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const options: IDataObject = {
|
||||
version: 'download_url',
|
||||
binaryNamingScheme: 'index',
|
||||
};
|
||||
|
||||
await expect(
|
||||
downloadAttachments.call(mockExecuteFunctions, submission, options),
|
||||
).rejects.toThrow(NodeOperationError);
|
||||
});
|
||||
|
||||
it('should throw NodeOperationError for invalid redirect URL', async () => {
|
||||
const submission: IDataObject = {
|
||||
_id: 123,
|
||||
_attachments: [
|
||||
{
|
||||
filename: 'photo.jpg',
|
||||
download_url: 'https://kf.kobotoolbox.org/attachment',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const options: IDataObject = {
|
||||
version: 'download_url',
|
||||
binaryNamingScheme: 'index',
|
||||
};
|
||||
|
||||
mockHttpRequest.mockResolvedValue({
|
||||
headers: { location: 'https://malicious.com/redirect' },
|
||||
});
|
||||
|
||||
await expect(
|
||||
downloadAttachments.call(mockExecuteFunctions, submission, options),
|
||||
).rejects.toThrow(NodeOperationError);
|
||||
});
|
||||
|
||||
it('should handle missing response body gracefully', async () => {
|
||||
const submission: IDataObject = {
|
||||
_id: 123,
|
||||
_attachments: [
|
||||
{
|
||||
filename: 'photo.jpg',
|
||||
download_url: 'https://kf.kobotoolbox.org/attachment',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const options: IDataObject = {
|
||||
version: 'download_url',
|
||||
binaryNamingScheme: 'index',
|
||||
dataPropertyAttachmentsPrefixName: 'attachment_',
|
||||
};
|
||||
|
||||
mockHttpRequest.mockResolvedValue({
|
||||
headers: {},
|
||||
});
|
||||
|
||||
const result = await downloadAttachments.call(mockExecuteFunctions, submission, options);
|
||||
|
||||
expect(result.json).toEqual(submission);
|
||||
expect(result.binary).toEqual({});
|
||||
expect(mockPrepareBinaryData).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should fallback to index naming when question not found', async () => {
|
||||
const submission: IDataObject = {
|
||||
_id: 123,
|
||||
other_field: 'value',
|
||||
_attachments: [
|
||||
{
|
||||
filename: 'photo.jpg',
|
||||
download_url: 'https://kf.kobotoolbox.org/attachment',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const options: IDataObject = {
|
||||
version: 'download_url',
|
||||
binaryNamingScheme: 'question',
|
||||
dataPropertyAttachmentsPrefixName: 'file_',
|
||||
};
|
||||
|
||||
mockHttpRequest.mockResolvedValue({
|
||||
body: Buffer.from('data'),
|
||||
headers: {},
|
||||
});
|
||||
|
||||
mockPrepareBinaryData.mockResolvedValue({ data: 'base64-data' });
|
||||
|
||||
const result = await downloadAttachments.call(mockExecuteFunctions, submission, options);
|
||||
|
||||
expect(result.binary!.file_0).toBeDefined();
|
||||
});
|
||||
|
||||
it('should handle maximum redirect limit', async () => {
|
||||
const submission: IDataObject = {
|
||||
_id: 123,
|
||||
_attachments: [
|
||||
{
|
||||
filename: 'photo.jpg',
|
||||
download_url: 'https://kf.kobotoolbox.org/attachment',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const options: IDataObject = {
|
||||
version: 'download_url',
|
||||
binaryNamingScheme: 'index',
|
||||
dataPropertyAttachmentsPrefixName: 'attachment_',
|
||||
};
|
||||
|
||||
// Mock 4 redirects then final response (loop condition: !final && redir < 5)
|
||||
// redir starts at 0, increments after each redirect
|
||||
// Loop runs when redir is 0,1,2,3,4 (5 iterations max)
|
||||
mockHttpRequest
|
||||
.mockResolvedValueOnce({ headers: { location: 'https://kc.kobotoolbox.org/r1' } })
|
||||
.mockResolvedValueOnce({ headers: { location: 'https://kc.kobotoolbox.org/r2' } })
|
||||
.mockResolvedValueOnce({ headers: { location: 'https://kc.kobotoolbox.org/r3' } })
|
||||
.mockResolvedValueOnce({ headers: { location: 'https://kc.kobotoolbox.org/r4' } })
|
||||
.mockResolvedValueOnce({
|
||||
body: Buffer.from('data'),
|
||||
headers: {},
|
||||
});
|
||||
|
||||
mockPrepareBinaryData.mockResolvedValue({ data: 'base64-data' });
|
||||
|
||||
const result = await downloadAttachments.call(mockExecuteFunctions, submission, options);
|
||||
|
||||
// Should make 5 total requests: 4 redirects + 1 final
|
||||
expect(result.binary!.attachment_0).toBeDefined();
|
||||
expect(mockHttpRequest).toHaveBeenCalledTimes(5);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user