The Third Bear

Just Right.

Actionkit forms with file upload fields

egj actionkit

You may sometimes need to associate uploaded files with Actionkit form submissions.  Broadly speaking, this can be done in two ways:

  1. Let users submit an Actionkit form, then land on a custom page hosted outside of Actionkit where they can upload a file to a server endpoint you've set up.  Capture the user's akid and action id from the query string parameters, and store those in your external database alongside the uploaded file.
  2. Add a file field to your Actionkit form, and hook in some Javascript to upload the file to a server as soon as it's been chosen by the user -- before the form is submitted.  Grab the uploaded file's URL, and stuff it in a hidden action field to be stored in the Actionkit database.

I think the second way is usually preferable: it provides a better user experience (fewer steps) so you're likely to get more uploads; you can set up validation rules to (more or less) require an upload in order to submit the form, if desired; and all your data lives directly in Actionkit, with file URL references right alongside the actions.

If you use an Amazon S3 bucket, this can be set up entirely client-side, with no server endpoint needed to accept the file uploads.

First, you'll need to make sure your S3 bucket is set to be world-writeable, with no public listings of files, and with a CORS configuration that allows cross-domain Ajax requests from your Actionkit hostname.

Then, you can use the handy Amazon Javascript SDK to upload files directly from an end user's browser to your S3 bucket.

Since the bucket needs to be world-writeable, and since individual files within it need to be world-readable, you'll want to generate completely unguessable random URLs for each newly created file.  That prevents anyone from (inadvertently or maliciously) overwriting existing files, and prevents users from downloading user submitted files unless they already know the URL.

Here's some sample Javascript code (with a "your-actionkit-user-submitted-file-upload-bucket" placeholder which you'll need to replace) that implements this approach:

(function() {
  'use strict'
  
  var s3 = new AWS.S3();
  
  var app = {};
  var h = app.helpers = {};

  h.uuid4 = function () {
    //// return uuid of form xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx
    var uuid = '', ii;
    for (ii = 0; ii < 32; ii += 1) {
      switch (ii) {
      case 8:
      case 20:
        uuid += '-';
        uuid += (Math.random() * 16 | 0).toString(16);
        break;
      case 12:
        uuid += '-';
        uuid += '4';
        break;
      case 16:
        uuid += '-';
        uuid += (Math.random() * 4 | 8).toString(16);
        break;
      default:
        uuid += (Math.random() * 16 | 0).toString(16);
      }
    }
    return uuid;
  };

  h.upload = function(file, s3PathPrefix, fieldName, fieldGuid, form) {
    var fileType = file.type,
        fileName = h.uuid4() + '-' + (new Date().toISOString()) + '-' + file.name;

    var params = {
      Bucket: 'your-actionkit-user-submitted-file-upload-bucket', 
      Key: s3PathPrefix + fileName,
      ACL: 'public-read',
      Body: file
    };

    s3.makeUnauthenticatedRequest('putObject', params, function(err, data) {
      if (err) {

      } else {
        var url = 'https://your-actionkit-user-submitted-file-upload-bucket.s3.amazonaws.com/' + s3PathPrefix + encodeURIComponent(fileName);
        $('<input type="hidden">')
          .attr('name', 'action_s3upload_' + fieldName).val(url)
          .attr('data-upload-guid', fieldGuid)
          .appendTo(form);
      }
    });
  };
  
  h.init = function() {
    $("form.ak-form input[type=file]").each(function() {
      // On init, mark each file input with a unique id, to track the uploads attributable to it
      if ($(this).data("upload-guid")) {
        return;
      }
      $(this).attr('data-upload-guid', h.guid());
    }).off("change").on("change", function() {
      // First remove any existing upload-url actionfields previously created by this file input
      $("input[name='action_s3upload_" + $(this).attr("name") + "'][data-upload-guid='" + $(this).data("upload-guid") + "']").remove();
      for (var i=0; i<this.files.length; ++i) {
        h.upload(
          this.files[i],
          $(this).data("upload-path") || '',
          $(this).attr("name"),
          $(this).data("upload-guid"),
          $(this).closest(".ak-form")
        );
      }
    });
  };
  
  $(document).ready(function() {
    h.init();
  });
})();

The way it works:

  1. On page load, it looks for any file inputs in your Actionkit form.
  2. For each file input that it founds, it adds a "change" event listener, which will be fired immediately when a user clicks the browser's native "Select file" button and chooses a file to upload from their computer.
  3. The event listener grabs the contents of the chosen file; builds a unique unguessable URL by combining the file's original name and extension with a UID and timestamp; and uploads the file to S3. 
  4. It then creates a hidden actionfield form field with the file's URL as a value, and a name derived from the original file input's name.
  5. It also does a bit of work to avoid tracking multiple files that shouldn't be tracked -- if a user selects one file, then goes back and selects a different file before submitting the form, only the most recently selected file should end up with its URL in an actionfield.

So, to use it, all you need to do is create an Actionkit survey page, add one or more survey fields with custom html like <label>Upload your file here: <input name="file1" type="file"></label>, and include the AWS JS SDK plus the above Javascript in your survey template.  Users will then see a completely normal-looking web form with a file upload field, and your submitted actions will have custom actionfields like "s3upload_file1" with the URLs of all the uploaded files.

A few ways this approach can be extended -- if you're concerned about having a world-writeable bucket without restrictions, you can include a server endpoint that validates file uploads by size, mimetype, or other criteria and creates temporary signed S3 URLs for the client to use.  And depending on your application, you might want to use Dropbox's Chooser widget, or Cloudinary's "select from local OR dropbox OR facebook OR instagram" uploader if you're specifically asking for images, etc.  But the basic approach is the same: collect and upload files from the client to a third-party service, then stash the resulting URL in an actionfield before form submission.  And in most cases the simple S3 version works well enough.