Corrupted PDF when parsing fax data from Twilio multipart/form-data response using AWS Lambda function

Viewed 266

I'm trying to use the Twilio Programmable Fax API to securely receive faxes in PDF format using AWS Lambda (Node.js environment) and API Gateway.

There are two Lambda functions involved to receive the faxed document. The first one is a webhook configured in the Twilio admin, and the second is specified in my TwiML response to Twilio from the first webhook.

Here is the implementation of my webhook:

module.exports.faxSentSecure = async (event, context) => {
  const twiml = `
  <Response>
    <Receive action="https://bd3j05rkk8.execute-api.ca-central-1.amazonaws.com/dev/v1/webhooks/twilio/fax/received/secure" storeMedia="false"/>
  </Response>
  `;

  const response = {
    statusCode: 200,
    headers: {
      "Content-Type": "text/xml",
    },
    body: twiml,
  };

  console.log(response);

  return response;
};

Shortly after responding with the above TwiML, Twilio will call the endpoint I've specified in the action parameter.

A few things to note at this point:

  • For compliance reasons, in my TwiML response above I'm specifying storeMedia="true" which causes Twilio avoid media storage and to call my specified action endpoint with the fax data as multipart/form-data where the actual PDF data is embedded in a parameter called Media.
  • Apparently in order to parse multipart/form-data using AWS Lambda functions, according to my research I need to specify it as a binary type in my API gateway settings. With this configuration, after I respond with the above TwiML, my Lambda function gets called and the body parameter of the event object I receive is a Base 64 encoded string (as opposed to UTF-8 if the binary type was not specified).

The issue I'm running into is when I try to parse out the PDF data to be stored somewhere (in my case it's AWS S3), I end up with a corrupted/unreadable PDF document.

Here is the implementation of my second webhook which receives the faxed document:

"use strict";

const querystring = require("querystring");
const fetch = require("node-fetch");
const stream = require("stream");
const Busboy = require("busboy");
const AWS = require("aws-sdk");

const s3 = new AWS.S3();

module.exports.faxReceivedSecure = async (event, context) => {
  console.log(JSON.stringify(event));
  console.log(JSON.stringify(event.headers));
  console.log(JSON.stringify(event.body));

  // decode the multipart form
  const base64Body = event.body; // base64 body from API gateway
  const decodedBody = Buffer.from(base64Body, "base64").toString("utf8");

  let form;
  try {
    form = await parseForm(event.headers, decodedBody);
    console.log("PARSED FORM", form);
  } catch (parseError) {
    console.log("PARSE ERROR: ", parseError);
  }

  // write the PDF to disk
  if (form) {
    const filename = form.Filename;

    // this results in corrupted PDF data
    const media = form.Media;
    const s3UploadResult = await s3
      .putObject({
        Bucket: process.env.BUCKET,
        Key: filename,
        Body: media,
      })
      .promise();

    console.log("S3 UPLOAD RESULT: ", s3UploadResult);

    const response = {
      statusCode: 200,
      body: JSON.stringify({
        message: "Fax Received!",
        s3UploadResult: s3UploadResult,
      }),
    };
    return response;
  } else {
    const response = {
      statusCode: 404,
      body: JSON.stringify({
        message: "Unable to parse fax data!",
      }),
    };
    return response;
  }
};

const parseForm = (headers, body) => {
  return new Promise((resolve, reject) => {
    let form = {};

    const contentType = headers["Content-Type"] || headers["content-type"];
    const busboy = new Busboy({ headers: { "content-type": contentType } });

    busboy.on("field", (fieldname, val) => {
      form[fieldname] = val;
    });

    busboy.on("file", (fieldname, file, filename, encoding, mimetype) => {
      console.log(
        "File [%s]: filename=%j; encoding=%j; mimetype=%j",
        fieldname,
        filename,
        encoding,
        mimetype
      );

      form.Fieldname = fieldname;
      form.Filename = filename;
      form.Encoding = encoding;
      form.Mimetype = mimetype;

      file.on("data", (data) => {
        console.log("File [%s] got %d bytes", fieldname, data.length);
        form[fieldname] = data;
      });

      file.on("end", () => console.log("File [%s] Finished", fieldname));
    });

    busboy.on("finish", () => {
      resolve(form);
    });

    busboy.on("error", (err) => {
      reject(err);
    });

    busboy.end(body);
  });
};

And the resolved object from the parseForm(headers, body) function looks like this:

{
  NumPages: '2',
  BitRate: '14400',
  Resolution: 'fine',
  FaxSid: 'FX467437b3f418ee4dfac5e0838906f226',
  Fieldname: 'Media',
  Filename: 'fax_FX467437b3f418ee4dfac5e0838906f226_AC8a237b34db1faa8c9dd6a8ee96ca38ae.pdf',
  Encoding: '7bit',
  Mimetype: 'application/pdf',
  To: '+18332003898',
  AccountSid: 'AC8a237b34db1faa8c9dd6a8ee96ca38ae',
  FaxStatus: 'received',
  RemoteStationId: 'FaxZero.com',
  From: '+12014799379',
  ApiVersion: 'v1',
  Media: <Buffer 25 50 44 46 2d 31 2e 31 20 0a 25 c3 a2 c3 a3 c3 8f c3 93 0a 31 20 30 20 6f 62 6a 0a 3c 3c 20 0a 2f 54 79 70 65 20 2f 43 61 74 61 6c 6f 67 20 0a 2f 50 ... 33847 more bytes>,
  Status: 'received'
}

I'm using busboy to parse the multipart form, and as you can see from the above output I'm able to extract all the data from the form. You can even see that the Media parameter is extracted as a buffer object.

However when I now try to store this buffer object either by writing it to disk (using a local express test server) or by uploading it to S3 all I get is a corrupted looking PDF.

The only issue I can think of that's happening here is that I'm incorrectly decoding the body data and causing the corruption of the PDF data.

I've been scratching my head for days on this and it's driving me nuts. Help would be greatly appreciated.

1 Answers

Ok so I've got a working solution now.

As I suspected the problem here is that we're converting the binary data to UTF-8 which in itself contains base64 encoded PDF data. We're then trying to interpret the PDF as base64 instead of UTF-8, but if we try to interpret the data as UTF-8 we've also lost/corrupted some of the information. Ideally all we want is to work with the base64 data directly without converting to/from base6/UTF-8 as that's where the data corruption is occurring.

After working with Twilio support on this we figured out that busboy is actually capable of parsing base64 data directly, there's no need to first convert it to UTF-8.

Long story short, here's the working code:

"use strict";

const Busboy = require("busboy");
const AWS = require("aws-sdk");

const S3 = new AWS.S3();

module.exports.faxReceivedSecure = async (event, context) => {
  // console.log(JSON.stringify(event));
  // console.log(JSON.stringify(event.headers));
  // console.log(JSON.stringify(event.body));

  // step 1
  // parse the multipart/form-data
  let form;
  try {
    // multipart/form-data is configured as a binary type in
    // API Gateway, therefore the body data is received as a
    // base64 encoded string
    const base64BodyString = event.body;

    // convert to base64 buffer
    const base64Body = Buffer.from(base64BodyString, "base64");
    form = await parseForm(event.headers, base64Body);
    console.log("PARSED FORM", form);
  } catch (parseError) {
    console.log("PARSE ERROR: ", parseError);
  }

  // step 2
  if (form) {
    // upload to S3
    const filename = form.Filename;
    const media = form.Media;
    const params = {
      Body: media,
      Bucket: process.env.BUCKET,
      Key: filename,
      Tagging: "key1=value1&source=twilio&type=fax",
    };
    const s3UploadResult = await S3.putObject(params).promise();

    console.log("S3 UPLOAD RESULT: ", s3UploadResult);

    const response = {
      statusCode: 200,
      body: JSON.stringify({
        message: "Fax Received!",
        s3UploadResult: s3UploadResult,
      }),
    };
    return response;
  } else {
    const response = {
      statusCode: 404,
      body: JSON.stringify({
        message: "Unable to parse fax data!",
      }),
    };
    return response;
  }
};

const parseForm = (headers, body) => {
  return new Promise((resolve, reject) => {
    let form = {};

    const contentType = headers["Content-Type"] || headers["content-type"];
    const busboy = new Busboy({ headers: { "content-type": contentType } });

    busboy.on("field", (fieldname, val) => {
      form[fieldname] = val;
    });

    busboy.on("file", (fieldname, file, filename, encoding, mimetype) => {
      console.log(
        "File [%s]: filename=%j; encoding=%j; mimetype=%j",
        fieldname,
        filename,
        encoding,
        mimetype
      );

      form.Fieldname = fieldname;
      form.Filename = filename;
      form.Encoding = encoding;
      form.Mimetype = mimetype;

      file.on("data", (data) => {
        console.log("File [%s] got %d bytes", fieldname, data.length);
        form[fieldname] = data;
      });

      file.on("end", () => console.log("File [%s] Finished", fieldname));
    });

    busboy.on("finish", () => {
      resolve(form);
    });

    busboy.on("error", (err) => {
      reject(err);
    });

    busboy.end(body);
  });
};
Related