From 232d85ebaaf903b7b767a7d28006df60bdd0106f Mon Sep 17 00:00:00 2001
From: Jared Hancock <jared@osticket.com>
Date: Wed, 20 Aug 2014 16:39:08 -0500
Subject: [PATCH] forms: Add file upload field type

---
 css/filedrop.css        | 147 +++++++++
 include/ajax.forms.php  |  18 ++
 include/class.forms.php | 205 ++++++++++++
 js/filedrop.field.js    | 684 ++++++++++++++++++++++++++++++++++++++++
 scp/ajax.php            |   4 +-
 5 files changed, 1057 insertions(+), 1 deletion(-)
 create mode 100644 css/filedrop.css
 create mode 100644 js/filedrop.field.js

diff --git a/css/filedrop.css b/css/filedrop.css
new file mode 100644
index 000000000..3f617b2d7
--- /dev/null
+++ b/css/filedrop.css
@@ -0,0 +1,147 @@
+.filedrop .dropzone {
+    border: 2px dashed rgba(0, 0, 0, 0.2);
+    padding: 8px;
+    border-radius: 5px;
+    background-color: rgba(0, 0, 0, 0.05);
+    color: #999;
+}
+.filedrop .dropzone a {
+    color: rgba(24, 78, 129, 0.7);
+}
+.filedrop .files:not(:empty) {
+    border: 1px solid rgba(0, 0, 0, 0.2);
+    border-radius: 5px 5px 0 0;
+    padding: 5px;
+}
+.filedrop .files:not(:empty) + .dropzone {
+    border-top: none;
+    border-radius: 0 0 5px 5px;
+}
+.filedrop .files .file {
+    display: block;
+    padding: 5px 10px 5px 20px;
+    margin: 0;
+    border-radius: 5px;
+}
+.filedrop .files .file:hover {
+   background-color: rgba(0, 0, 0, 0.05);
+}
+.filedrop .files .file .filesize {
+  margin-left: 1em;
+  color: #999;
+}
+.filedrop .files .file .upload-rate {
+  margin-right: 10px;
+  color: #aaa;
+}
+.filedrop .files .file .trash {
+  cursor: pointer;
+}
+
+/* Bootstrap 3.2 progress-bar */
+@-webkit-keyframes progress-bar-stripes {
+  from {
+    background-position: 40px 0;
+  }
+  to {
+    background-position: 0 0;
+  }
+}
+@-o-keyframes progress-bar-stripes {
+  from {
+    background-position: 40px 0;
+  }
+  to {
+    background-position: 0 0;
+  }
+}
+@keyframes progress-bar-stripes {
+  from {
+    background-position: 40px 0;
+  }
+  to {
+    background-position: 0 0;
+  }
+}
+.progress {
+  height: 10px;
+  overflow: hidden;
+  background-color: #f5f5f5;
+  border-radius: 4px;
+  -webkit-box-shadow: inset 0 1px 2px rgba(0, 0, 0, .1);
+          box-shadow: inset 0 1px 2px rgba(0, 0, 0, .1);
+}
+.progress-bar {
+  float: left;
+  width: 0;
+  height: 100%;
+  font-size: 12px;
+  line-height: 20px;
+  color: #fff;
+  text-align: center;
+  background-color: #428bca;
+  -webkit-box-shadow: inset 0 -1px 0 rgba(0, 0, 0, .15);
+          box-shadow: inset 0 -1px 0 rgba(0, 0, 0, .15);
+  -webkit-transition: width .6s ease;
+       -o-transition: width .6s ease;
+          transition: width .6s ease;
+}
+.progress-striped .progress-bar,
+.progress-bar-striped {
+  background-image: -webkit-linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent);
+  background-image:      -o-linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent);
+  background-image:         linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent);
+  -webkit-background-size: 40px 40px;
+          background-size: 40px 40px;
+}
+.progress.active .progress-bar,
+.progress-bar.active {
+  -webkit-animation: progress-bar-stripes 2s linear infinite;
+       -o-animation: progress-bar-stripes 2s linear infinite;
+          animation: progress-bar-stripes 2s linear infinite;
+}
+.progress-bar[aria-valuenow="1"],
+.progress-bar[aria-valuenow="2"] {
+  min-width: 30px;
+}
+.progress-bar[aria-valuenow="0"] {
+  min-width: 30px;
+  color: #777;
+  background-color: transparent;
+  background-image: none;
+  -webkit-box-shadow: none;
+          box-shadow: none;
+}
+.progress-bar-success {
+  background-color: #5cb85c;
+}
+.progress-striped .progress-bar-success {
+  background-image: -webkit-linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent);
+  background-image:      -o-linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent);
+  background-image:         linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent);
+}
+.progress-bar-info {
+  background-color: #5bc0de;
+}
+.progress-striped .progress-bar-info {
+  background-image: -webkit-linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent);
+  background-image:      -o-linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent);
+  background-image:         linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent);
+}
+.progress-bar-warning {
+  background-color: #f0ad4e;
+}
+.progress-striped .progress-bar-warning {
+  background-image: -webkit-linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent);
+  background-image:      -o-linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent);
+  background-image:         linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent);
+}
+.progress-bar-danger {
+  background-color: #d9534f;
+}
+.progress-striped .progress-bar-danger {
+  background-image: -webkit-linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent);
+  background-image:      -o-linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent);
+  background-image:         linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent);
+}
+
diff --git a/include/ajax.forms.php b/include/ajax.forms.php
index 84b2cb3d8..cbd0ae46e 100644
--- a/include/ajax.forms.php
+++ b/include/ajax.forms.php
@@ -2,6 +2,7 @@
 
 require_once(INCLUDE_DIR . 'class.topic.php');
 require_once(INCLUDE_DIR . 'class.dynamic_forms.php');
+require_once(INCLUDE_DIR . 'class.forms.php');
 
 class DynamicFormsAjaxAPI extends AjaxController {
     function getForm($form_id) {
@@ -81,5 +82,22 @@ class DynamicFormsAjaxAPI extends AjaxController {
 
         Http::response(201, 'Successfully updated record');
     }
+
+    function upload($id) {
+        if (!$field = DynamicFormField::lookup($id))
+            Http::response(400, 'No such field');
+
+        $impl = $field->getImpl();
+        if (!$impl instanceof FileUploadField)
+            Http::response(400, 'Upload to a non file-field');
+
+        return $impl->upload();
+    }
+
+    function attach() {
+        $field = new FileUploadField();
+        $field->loadSystemDefaultConfig();
+        return $field->upload();
+    }
 }
 ?>
diff --git a/include/class.forms.php b/include/class.forms.php
index a20b33092..7f8a0f935 100644
--- a/include/class.forms.php
+++ b/include/class.forms.php
@@ -168,6 +168,7 @@ class FormField {
             'phone' => array(   /* @trans */ 'Phone Number', 'PhoneField'),
             'bool' => array(    /* @trans */ 'Checkbox', 'BooleanField'),
             'choices' => array( /* @trans */ 'Choices', 'ChoiceField'),
+            'files' => array(   /* @trans */ 'File Upload', 'FileUploadField'),
             'break' => array(   /* @trans */ 'Section Break', 'SectionBreakField'),
         ),
     );
@@ -1206,6 +1207,145 @@ FormField::addFieldTypes('Dynamic Fields', function() {
     );
 });
 
+class FileUploadField extends FormField {
+    static $widget = 'FileUploadWidget';
+
+    protected $attachments;
+
+    function getConfigurationOptions() {
+        // Compute size selections
+        $sizes = array('262144' => '— Small —');
+        $next = 512 << 10;
+        $max = strtoupper(ini_get('upload_max_filesize'));
+        $limit = (int) $max;
+        if (!$limit) $limit = 2 << 20; # 2M default value
+        elseif (strpos($max, 'K')) $limit <<= 10;
+        elseif (strpos($max, 'M')) $limit <<= 20;
+        elseif (strpos($max, 'G')) $limit <<= 30;
+        while ($next <= $limit) {
+            // Select the closest, larger value (in case the
+            // current value is between two)
+            $diff = $next - $config['max_file_size'];
+            $sizes[$next] = Format::file_size($next);
+            $next *= 2;
+        }
+        // Add extra option if top-limit in php.ini doesn't fall
+        // at a power of two
+        if ($next < $limit * 2)
+            $sizes[$limit] = Format::file_size($limit);
+
+        return array(
+            'size' => new ChoiceField(array(
+                'label'=>'Maximum File Size',
+                'hint'=>'Maximum size of a single file uploaded to this field',
+                'default'=>'262144',
+                'choices'=>$sizes
+            )),
+            'extensions' => new TextareaField(array(
+                'label'=>'Allowed Extensions',
+                'hint'=>'Enter allowed file extensions separated by a comma.
+                e.g .doc, .pdf. To accept all files enter wildcard
+                <b><i>.*</i></b> — i.e dotStar (NOT Recommended).',
+                'default'=>'.doc, .pdf, .jpg, .jpeg, .gif, .png, .xls, .docx, .xlsx, .txt',
+                'configuration'=>array('html'=>false, 'rows'=>2),
+            )),
+            'max' => new TextboxField(array(
+                'label'=>'Maximum Files',
+                'hint'=>'Users cannot upload more than this many files',
+                'default'=>false,
+                'required'=>false,
+                'validator'=>'number',
+                'configuration'=>array('size'=>4, 'length'=>4),
+            ))
+        );
+    }
+
+    function loadSystemDefaultConfig() {
+        global $cfg;
+        $this->_config = array(
+            'max' => $cfg->getStaffMaxFileUploads(),
+            'size' => $cfg->getMaxFileSize(),
+            'extensions' => $cfg->getAllowedFileTypes(),
+        );
+    }
+
+    function upload() {
+        $config = $this->getConfiguration();
+
+        $files = AttachmentFile::format($_FILES['upload'],
+            // For numeric fields assume configuration exists
+            is_numeric($this->get('id')));
+        if (count($files) != 1)
+            Http::response(400, 'Send one file at a time');
+        $file = array_shift($files);
+        $file['name'] = urldecode($file['name']);
+
+        // TODO: Check allowed type / size.
+        //       Return HTTP/413, 415, 417 or similar
+
+        if (!($id = AttachmentFile::upload($file)))
+            Http::response(500, 'Unable to store file');
+
+        Http::response(200, $id);
+    }
+
+    function getFiles() {
+        if (!isset($this->attachments) && ($a = $this->getAnswer())
+            && ($e = $a->getEntry()) && ($e->get('id'))
+        ) {
+            $this->attachments = new GenericAttachments(
+                // Combine the field and entry ids to make the key
+                sprintf('%u', crc32('E'.$this->get('id').$e->get('id'))),
+                'E');
+        }
+        return $this->attachments ? $this->attachments->getAll() : array();
+    }
+
+    // When the field is saved to database, encode the ID listing as a json
+    // array. Then, inspect the difference between the files actually
+    // attached to this field
+    function to_database($value) {
+        $this->getFiles();
+        if (isset($this->attachments)) {
+            $ids = array();
+            // Handle deletes
+            foreach ($this->attachments->getAll() as $f) {
+                if (!in_array($f['id'], $value))
+                    $this->attachments->delete($f['id']);
+                else
+                    $ids[] = $f['id'];
+            }
+            // Handle new files
+            foreach ($value as $id) {
+                if (!in_array($id, $ids))
+                    $this->attachments->upload($id);
+            }
+        }
+        return JsonDataEncoder::encode($value);
+    }
+
+    function parse($value) {
+        // Values in the database should be integer file-ids
+        return array_map(function($e) { return (int) $e; },
+            $value ?: array());
+    }
+
+    function to_php($value) {
+        return JsonDataParser::decode($value);
+    }
+
+    function display($value) {
+        $links = array();
+        foreach ($this->getFiles() as $f) {
+            $hash = strtolower($f['key']
+                . md5($f['id'].session_id().strtolower($f['key'])));
+            $links[] = sprintf('<a class="no-pjax" href="file.php?h=%s">%s</a>',
+                $hash, Format::htmlchars($f['name']));
+        }
+        return implode('<br/>', $links);
+    }
+}
+
 class Widget {
     static $media = null;
 
@@ -1568,4 +1708,69 @@ class ThreadEntryWidget extends Widget {
     }
 }
 
+class FileUploadWidget extends Widget {
+    static $media = array(
+        'js' => array(
+            '/js/filedrop.field.js'
+        ),
+        'css' => array(
+            '/css/filedrop.css',
+        ),
+    );
+
+    function render($how) {
+        $config = $this->field->getConfiguration();
+        $name = $this->field->getFormName();
+        $attachments = $this->field->getFiles();
+        $files = array();
+        foreach ($this->value ?: array() as $id) {
+            $found = false;
+            foreach ($attachments as $f) {
+                if ($f['id'] == $id) {
+                    $files[] = $f;
+                    $found = true;
+                    break;
+                }
+            }
+            if (!$found && ($file = AttachmentFile::lookup($id))) {
+                $files[] = array(
+                    'id' => $file->getId(),
+                    'name' => $file->getName(),
+                    'type' => $file->getType(),
+                    'size' => $file->getSize(),
+                );
+            }
+        }
+        ?><div id="filedrop-<?php echo $name;
+            ?>" class="filedrop"><div class="files"></div>
+            <div class="dropzone"><i class="icon-upload"></i>
+            Drop files here or <a href="#" class="manual">choose
+            them</a></div></div>
+        <input type="file" id="file-<?php echo $name; ?>" style="display:none;"/>
+        <script type="text/javascript">
+        $(function(){$('#filedrop-<?php echo $name; ?> .dropzone').filedropbox({
+          url: 'ajax.php/form/upload/<?php echo $this->field->get('id') ?>',
+          link: $('#filedrop-<?php echo $name; ?>').find('a.manual'),
+          paramname: 'upload[]',
+          fallback_id: 'file-<?php echo $name; ?>',
+          allowedfileextensions: '<?php echo $config['extensions'];
+          ?>'.split(/,\s*/),
+          maxfiles: <?php echo $config['max'] ?: 20; ?>,
+          maxfilesize: <?php echo $config['filesize'] ?: 1048576 / 1048576; ?>,
+          name: '<?php echo $name; ?>[]',
+          files: <?php echo JsonDataEncoder::encode($files); ?>
+        });});
+        </script>
+<?php
+    }
+
+    function getValue() {
+        $data = $this->field->getSource();
+        // If no value was sent, assume an empty list
+        if ($data && is_array($data) && !isset($data[$this->name]))
+            return array();
+        return parent::getValue();
+    }
+}
+
 ?>
diff --git a/js/filedrop.field.js b/js/filedrop.field.js
new file mode 100644
index 000000000..5b0249527
--- /dev/null
+++ b/js/filedrop.field.js
@@ -0,0 +1,684 @@
+!function($) {
+  "use strict";
+
+  var FileDropbox = function(element, options) {
+    this.$element = $(element);
+    this.uploads = [];
+
+    var events = {
+      uploadStarted: $.proxy(this.uploadStarted, this),
+      uploadFinished: $.proxy(this.uploadFinished, this),
+      progressUpdated: $.proxy(this.progressUpdated, this),
+      speedUpdated: $.proxy(this.speedUpdated, this),
+      dragOver: $.proxy(this.dragOver, this),
+      drop: $.proxy(this.dragLeave, this),
+      error: $.proxy(this.handleError, this)
+    };
+
+    this.options = $.extend({}, $.fn.filedropbox.defaults, events, options);
+    this.$element.filedrop(this.options);
+    (this.options.files || []).forEach($.proxy(this.addNode, this));
+  };
+
+  FileDropbox.prototype = {
+    drop: function(e) {
+        this.$element.removeAttr('style');
+    },
+    dragOver: function(box, e) {
+        this.$element.css('background-color', 'rgba(0, 0, 0, 0.3)');
+    },
+    speedUpdated: function(i, file, speed) {
+      var that = this;
+      this.uploads.some(function(e) {
+        if (e.data('file') == file) {
+          e.find('.upload-rate').text(that.fileSize(speed * 1024)+'/s');
+          return true;
+        }
+      });
+    },
+    progressUpdated: function(i, file, value) {
+      this.uploads.some(function(e) {
+        if (e.data('file') == file) {
+          e.find('.progress').show();
+          e.find('.progress-bar')
+            .width(value + '%')
+            .attr({'aria-valuenow': value})
+          if (value > 99)
+            e.find('.progress-bar').addClass('progress-bar-striped active')
+          return true;
+        }
+      });
+    },
+    uploadStarted: function(i, file, n) {
+      var node = this.addNode(file).data('file', file);
+      node.find('.trash').hide();
+      this.uploads.push(node);
+      this.progressUpdated(i, file, 0);
+    },
+    uploadFinished: function(i, file, response, time, xhr) {
+      var that = this;
+      this.uploads.some(function(e) {
+        if (e.data('file') == file) {
+          e.find('[name="'+that.options.name+'"]').val(response);
+          e.find('.progress-bar')
+            .width('100%')
+            .attr({'aria-valuenow': 100})
+          e.find('.trash').show();
+          e.find('.upload-rate').hide();
+          setTimeout(function() { e.find('.progress').hide(); }, 600);
+          return true;
+        }
+      });
+    },
+    fileSize: function(size) {
+      var sizes = ['k','M','G','T'],
+          suffix = '';
+      while (size > 900) {
+        size /= 1024;
+        suffix = sizes.shift();
+      }
+      return size.toPrecision(3) + suffix + 'B';
+    },
+    addNode: function(file) {
+      var filenode = $('<div class="file"></div>')
+          .append($('<div class="filetype"></div>').addClass())
+          .append($('<div class="filename"></div>').text(file.name)
+            .append($('<span class="filesize"></span>').text(
+              this.fileSize(file.size)
+            )).append($('<div class="upload-rate pull-right"></div>'))
+          ).append($('<div class="progress"></div>')
+            .append($('<div class="progress-bar"></div>'))
+            .attr({'aria-valuemin':0,'aria-valuemax':100})
+            .hide())
+          .append($('<input type="hidden"/>').attr('name', this.options.name)
+            .val(file.id));
+      if (this.options.deletable) {
+        filenode.prepend($('<span><i class="icon-trash"></i></span>')
+          .addClass('trash pull-right')
+          .click($.proxy(this.deleteNode, this, filenode))
+        );
+      }
+      this.$element.parent().find('.files').append(filenode);
+      return filenode;
+    },
+    deleteNode: function(filenode, e) {
+      if (confirm(__('You sure?')))
+        filenode.remove();
+    }
+  };
+
+  $.fn.filedropbox = function ( option ) {
+    return this.each(function () {
+      var $this = $(this),
+        data = $this.data('dropbox'),
+        options = typeof option == 'object' && option;
+      if (!data) $this.data('dropbox', (data = new FileDropbox(this, options)));
+      if (typeof option == 'string') data[option]();
+    });
+  };
+
+  $.fn.filedropbox.defaults = {
+    files: [],
+    deletable: true
+  };
+
+  $.fn.filedropbox.Constructor = FileDropbox;
+
+}(jQuery);
+
+/*
+ * Default text - jQuery plugin for html5 dragging files from desktop to browser
+ *
+ * Author: Weixi Yen
+ *
+ * Email: [Firstname][Lastname]@gmail.com
+ *
+ * Copyright (c) 2010 Resopollution
+ *
+ * Licensed under the MIT license:
+ *   http://www.opensource.org/licenses/mit-license.php
+ *
+ * Project home:
+ *   http://www.github.com/weixiyen/jquery-filedrop
+ *
+ * Version:  0.1.0
+ *
+ * Features:
+ *      Allows sending of extra parameters with file.
+ *      Works with Firefox 3.6+
+ *      Future-compliant with HTML5 spec (will work with Webkit browsers and IE9)
+ * Usage:
+ *  See README at project homepage
+ *
+ */
+;(function($) {
+
+  jQuery.event.props.push("dataTransfer");
+
+  var default_opts = {
+      fallback_id: '',
+      link: false,
+      url: '',
+      refresh: 1000,
+      paramname: 'userfile',
+      requestType: 'POST',    // just in case you want to use another HTTP verb
+      allowedfileextensions:[],
+      allowedfiletypes:[],
+      maxfiles: 25,           // Ignored if queuefiles is set > 0
+      maxfilesize: 1,         // MB file size limit
+      queuefiles: 0,          // Max files before queueing (for large volume uploads)
+      queuewait: 200,         // Queue wait time if full
+      data: {},
+      headers: {},
+      drop: empty,
+      dragStart: empty,
+      dragEnter: empty,
+      dragOver: empty,
+      dragLeave: empty,
+      docEnter: empty,
+      docOver: empty,
+      docLeave: empty,
+      beforeEach: empty,
+      afterAll: empty,
+      rename: empty,
+      error: function(err, file, i, status) {
+        alert(err);
+      },
+      uploadStarted: empty,
+      uploadFinished: empty,
+      progressUpdated: empty,
+      globalProgressUpdated: empty,
+      speedUpdated: empty
+      },
+      errors = ["BrowserNotSupported", "TooManyFiles", "FileTooLarge", "FileTypeNotAllowed", "NotFound", "NotReadable", "AbortError", "ReadError", "FileExtensionNotAllowed"];
+
+  $.fn.filedrop = function(options) {
+    var opts = $.extend({}, default_opts, options),
+        global_progress = [],
+        doc_leave_timer, stop_loop = false,
+        files_count = 0,
+        files;
+
+    $('#' + opts.fallback_id).css({
+      display: 'none',
+      width: 0,
+      height: 0
+    });
+
+    this.on('drop', drop).on('dragstart', opts.dragStart).on('dragenter', dragEnter).on('dragover', dragOver).on('dragleave', dragLeave);
+    $(document).on('drop', docDrop).on('dragenter', docEnter).on('dragover', docOver).on('dragleave', docLeave);
+
+    (opts.link || this).on('click', function(e){
+      $('#' + opts.fallback_id).trigger(e);
+    });
+
+    $('#' + opts.fallback_id).change(function(e) {
+      opts.drop(e);
+      files = e.target.files;
+      files_count = files.length;
+      upload();
+    });
+
+    function drop(e) {
+      if( opts.drop.call(this, e) === false ) return false;
+      if(!e.dataTransfer)
+        return;
+      files = e.dataTransfer.files;
+      if (files === null || files === undefined || files.length === 0) {
+        opts.error(errors[0]);
+        return false;
+      }
+      files_count = files.length;
+      upload();
+      e.preventDefault();
+      return false;
+    }
+
+    function getBuilder(filename, filedata, mime, boundary) {
+      var dashdash = '--',
+          crlf = '\r\n',
+          builder = '',
+          paramname = opts.paramname;
+
+      if (opts.data) {
+        var params = $.param(opts.data).replace(/\+/g, '%20').split(/&/);
+
+        $.each(params, function() {
+          var pair = this.split("=", 2),
+              name = decodeURIComponent(pair[0]),
+              val  = decodeURIComponent(pair[1]);
+
+          if (pair.length !== 2) {
+              return;
+          }
+
+          builder += dashdash;
+          builder += boundary;
+          builder += crlf;
+          builder += 'Content-Disposition: form-data; name="' + name + '"';
+          builder += crlf;
+          builder += crlf;
+          builder += val;
+          builder += crlf;
+        });
+      }
+
+      if (jQuery.isFunction(paramname)){
+        paramname = paramname(filename);
+      }
+
+      builder += dashdash;
+      builder += boundary;
+      builder += crlf;
+      builder += 'Content-Disposition: form-data; name="' + (paramname||"") + '"';
+      builder += '; filename="' + encodeURIComponent(filename) + '"';
+      builder += crlf;
+
+      builder += 'Content-Type: ' + mime;
+      builder += crlf;
+      builder += crlf;
+
+      builder += filedata;
+      builder += crlf;
+
+      builder += dashdash;
+      builder += boundary;
+      builder += dashdash;
+      builder += crlf;
+      return builder;
+    }
+
+    function progress(e) {
+      if (e.lengthComputable) {
+        var percentage = Math.round((e.loaded * 100) / e.total);
+        if (this.currentProgress !== percentage) {
+
+          this.currentProgress = percentage;
+          opts.progressUpdated(this.index, this.file, this.currentProgress);
+
+          global_progress[this.global_progress_index] = this.currentProgress;
+          globalProgress();
+
+          var elapsed = new Date().getTime();
+          var diffTime = elapsed - this.currentStart;
+          if (diffTime >= opts.refresh) {
+            var diffData = e.loaded - this.startData;
+            var speed = diffData / diffTime; // KB per second
+            opts.speedUpdated(this.index, this.file, speed);
+            this.startData = e.loaded;
+            this.currentStart = elapsed;
+          }
+        }
+      }
+    }
+
+    function globalProgress() {
+      if (global_progress.length === 0) {
+        return;
+      }
+
+      var total = 0, index;
+      for (index in global_progress) {
+        if(global_progress.hasOwnProperty(index)) {
+          total = total + global_progress[index];
+        }
+      }
+
+      opts.globalProgressUpdated(Math.round(total / global_progress.length));
+    }
+
+    // Respond to an upload
+    function upload() {
+      stop_loop = false;
+
+      if (!files) {
+        opts.error(errors[0]);
+        return false;
+      }
+
+      if (opts.allowedfiletypes.push && opts.allowedfiletypes.length) {
+        for(var fileIndex = files.length;fileIndex--;) {
+          if(!files[fileIndex].type || $.inArray(files[fileIndex].type, opts.allowedfiletypes) < 0) {
+            opts.error(errors[3], files[fileIndex]);
+            return false;
+          }
+        }
+      }
+
+      if (opts.allowedfileextensions.push && opts.allowedfileextensions.length) {
+        for(var fileIndex = files.length;fileIndex--;) {
+          var allowedextension = false;
+          for (i=0;i<opts.allowedfileextensions.length;i++){
+            if (files[fileIndex].name.substr(files[fileIndex].name.length-opts.allowedfileextensions[i].length).toLowerCase()
+                    == opts.allowedfileextensions[i].toLowerCase()
+            ) {
+              allowedextension = true;
+            }
+          }
+          if (!allowedextension){
+            opts.error(errors[8], files[fileIndex]);
+            return false;
+          }
+        }
+      }
+
+      var filesDone = 0,
+          filesRejected = 0;
+
+      if (files_count > opts.maxfiles && opts.queuefiles === 0) {
+        opts.error(errors[1]);
+        return false;
+      }
+
+      // Define queues to manage upload process
+      var workQueue = [];
+      var processingQueue = [];
+      var doneQueue = [];
+
+      // Add everything to the workQueue
+      for (var i = 0; i < files_count; i++) {
+        workQueue.push(i);
+      }
+
+      // Helper function to enable pause of processing to wait
+      // for in process queue to complete
+      var pause = function(timeout) {
+        setTimeout(process, timeout);
+        return;
+      };
+
+      // Process an upload, recursive
+      var process = function() {
+
+        var fileIndex;
+
+        if (stop_loop) {
+          return false;
+        }
+
+        // Check to see if are in queue mode
+        if (opts.queuefiles > 0 && processingQueue.length >= opts.queuefiles) {
+          return pause(opts.queuewait);
+        } else {
+          // Take first thing off work queue
+          fileIndex = workQueue[0];
+          workQueue.splice(0, 1);
+
+          // Add to processing queue
+          processingQueue.push(fileIndex);
+        }
+
+        try {
+          if (beforeEach(files[fileIndex]) !== false) {
+            if (fileIndex === files_count) {
+              return;
+            }
+            var reader = new FileReader(),
+                max_file_size = 1048576 * opts.maxfilesize;
+
+            reader.index = fileIndex;
+            if (files[fileIndex].size > max_file_size) {
+              opts.error(errors[2], files[fileIndex], fileIndex);
+              // Remove from queue
+              processingQueue.forEach(function(value, key) {
+                if (value === fileIndex) {
+                  processingQueue.splice(key, 1);
+                }
+              });
+              filesRejected++;
+              return true;
+            }
+
+            reader.onerror = function(e) {
+                switch(e.target.error.code) {
+                    case e.target.error.NOT_FOUND_ERR:
+                        opts.error(errors[4]);
+                        return false;
+                    case e.target.error.NOT_READABLE_ERR:
+                        opts.error(errors[5]);
+                        return false;
+                    case e.target.error.ABORT_ERR:
+                        opts.error(errors[6]);
+                        return false;
+                    default:
+                        opts.error(errors[7]);
+                        return false;
+                };
+            };
+
+            reader.onloadend = !opts.beforeSend ? send : function (e) {
+              opts.beforeSend(files[fileIndex], fileIndex, function () { send(e); });
+            };
+
+            reader.readAsDataURL(files[fileIndex]);
+
+          } else {
+            filesRejected++;
+          }
+        } catch (err) {
+          // Remove from queue
+          processingQueue.forEach(function(value, key) {
+            if (value === fileIndex) {
+              processingQueue.splice(key, 1);
+            }
+          });
+          opts.error(errors[0]);
+          return false;
+        }
+
+        // If we still have work to do,
+        if (workQueue.length > 0) {
+          process();
+        }
+      };
+
+      var send = function(e) {
+
+        var fileIndex = (e.srcElement || e.target).index;
+
+        // Sometimes the index is not attached to the
+        // event object. Find it by size. Hack for sure.
+        if (e.target.index === undefined) {
+          e.target.index = getIndexBySize(e.total);
+        }
+
+        var xhr = new XMLHttpRequest(),
+            upload = xhr.upload,
+            file = files[e.target.index],
+            index = e.target.index,
+            start_time = new Date().getTime(),
+            boundary = '------multipartformboundary' + (new Date()).getTime(),
+            global_progress_index = global_progress.length,
+            builder,
+            newName = rename(file.name),
+            mime = file.type;
+
+        if (opts.withCredentials) {
+          xhr.withCredentials = opts.withCredentials;
+        }
+
+        var data = atob(e.target.result.split(',')[1]);
+        if (typeof newName === "string") {
+          builder = getBuilder(newName, data, mime, boundary);
+        } else {
+          builder = getBuilder(file.name, data, mime, boundary);
+        }
+
+        upload.index = index;
+        upload.file = file;
+        upload.downloadStartTime = start_time;
+        upload.currentStart = start_time;
+        upload.currentProgress = 0;
+        upload.global_progress_index = global_progress_index;
+        upload.startData = 0;
+        upload.addEventListener("progress", progress, false);
+
+        // Allow url to be a method
+        if (jQuery.isFunction(opts.url)) {
+            xhr.open(opts.requestType, opts.url(), true);
+        } else {
+            xhr.open(opts.requestType, opts.url, true);
+        }
+
+        xhr.setRequestHeader('content-type', 'multipart/form-data; boundary=' + boundary);
+        xhr.setRequestHeader("X-Requested-With", "XMLHttpRequest");
+
+        // Add headers
+        $.each(opts.headers, function(k, v) {
+          xhr.setRequestHeader(k, v);
+        });
+
+        xhr.sendAsBinary(builder);
+
+        global_progress[global_progress_index] = 0;
+        globalProgress();
+
+        opts.uploadStarted(index, file, files_count);
+
+        xhr.onload = function() {
+            var serverResponse = null;
+
+            if (xhr.responseText) {
+              try {
+                serverResponse = jQuery.parseJSON(xhr.responseText);
+              }
+              catch (e) {
+                serverResponse = xhr.responseText;
+              }
+            }
+
+            var now = new Date().getTime(),
+                timeDiff = now - start_time,
+                result = opts.uploadFinished(index, file, serverResponse, timeDiff, xhr);
+            filesDone++;
+
+            // Remove from processing queue
+            processingQueue.forEach(function(value, key) {
+              if (value === fileIndex) {
+                processingQueue.splice(key, 1);
+              }
+            });
+
+            // Add to donequeue
+            doneQueue.push(fileIndex);
+
+            // Make sure the global progress is updated
+            global_progress[global_progress_index] = 100;
+            globalProgress();
+
+            if (filesDone === (files_count - filesRejected)) {
+              afterAll();
+            }
+            if (result === false) {
+              stop_loop = true;
+            }
+
+
+          // Pass any errors to the error option
+          if (xhr.status < 200 || xhr.status > 299) {
+            opts.error(xhr.statusText, file, fileIndex, xhr.status);
+          }
+        };
+      };
+
+      // Initiate the processing loop
+      process();
+    }
+
+    function getIndexBySize(size) {
+      for (var i = 0; i < files_count; i++) {
+        if (files[i].size === size) {
+          return i;
+        }
+      }
+
+      return undefined;
+    }
+
+    function rename(name) {
+      return opts.rename(name);
+    }
+
+    function beforeEach(file) {
+      return opts.beforeEach(file);
+    }
+
+    function afterAll() {
+      return opts.afterAll();
+    }
+
+    function dragEnter(e) {
+      clearTimeout(doc_leave_timer);
+      e.preventDefault();
+      opts.dragEnter.call(this, e);
+    }
+
+    function dragOver(e) {
+      clearTimeout(doc_leave_timer);
+      e.preventDefault();
+      opts.docOver.call(this, e);
+      opts.dragOver.call(this, e);
+    }
+
+    function dragLeave(e) {
+      clearTimeout(doc_leave_timer);
+      opts.dragLeave.call(this, e);
+      e.stopPropagation();
+    }
+
+    function docDrop(e) {
+      e.preventDefault();
+      opts.docLeave.call(this, e);
+      return false;
+    }
+
+    function docEnter(e) {
+      clearTimeout(doc_leave_timer);
+      e.preventDefault();
+      opts.docEnter.call(this, e);
+      return false;
+    }
+
+    function docOver(e) {
+      clearTimeout(doc_leave_timer);
+      e.preventDefault();
+      opts.docOver.call(this, e);
+      return false;
+    }
+
+    function docLeave(e) {
+      doc_leave_timer = setTimeout((function(_this) {
+        return function() {
+          opts.docLeave.call(_this, e);
+        };
+      })(this), 200);
+    }
+
+    return this;
+  };
+
+  function empty() {}
+
+  try {
+    if (XMLHttpRequest.prototype.sendAsBinary) {
+        return;
+    }
+    XMLHttpRequest.prototype.sendAsBinary = function(datastr) {
+      function byteValue(x) {
+        return x.charCodeAt(0) & 0xff;
+      }
+      var ords = Array.prototype.map.call(datastr, byteValue);
+      var ui8a = new Uint8Array(ords);
+
+      // Not pretty: Chrome 22 deprecated sending ArrayBuffer, moving instead
+      // to sending ArrayBufferView.  Sadly, no proper way to detect this
+      // functionality has been discovered.  Happily, Chrome 22 also introduced
+      // the base ArrayBufferView class, not present in Chrome 21.
+      if ('ArrayBufferView' in window)
+        this.send(ui8a);
+      else
+        this.send(ui8a.buffer);
+    };
+  } catch (e) {}
+
+})(jQuery);
diff --git a/scp/ajax.php b/scp/ajax.php
index cdeec76d9..7cfe289d5 100644
--- a/scp/ajax.php
+++ b/scp/ajax.php
@@ -55,7 +55,9 @@ $dispatcher = patterns('',
         url_get('^help-topic/(?P<id>\d+)$', 'getFormsForHelpTopic'),
         url_get('^field-config/(?P<id>\d+)$', 'getFieldConfiguration'),
         url_post('^field-config/(?P<id>\d+)$', 'saveFieldConfiguration'),
-        url_delete('^answer/(?P<entry>\d+)/(?P<field>\d+)$', 'deleteAnswer')
+        url_delete('^answer/(?P<entry>\d+)/(?P<field>\d+)$', 'deleteAnswer'),
+        url_post('^upload/(\d+)?$', 'upload'),
+        url_post('^upload/(\w+)?$', 'attach')
     )),
     url('^/list/', patterns('ajax.forms.php:DynamicFormsAjaxAPI',
         url_get('^(?P<list>\w+)/item/(?P<id>\d+)/properties$', 'getListItemProperties'),
-- 
GitLab