diff --git a/attachment.php b/attachment.php
index 1c0941d7d545d5bbdfaeaa50029d9900ba6e5b39..73dbfd710b9a58f164034d35239842f01a102f17 100644
--- a/attachment.php
+++ b/attachment.php
@@ -25,7 +25,7 @@ if(!$thisclient
     die('Unknown attachment!');
 
 //Validate session access hash - we want to make sure the link is FRESH! and the user has access to the parent ticket!!
-$vhash=md5($attachment->getFileId().session_id().$file->getHash());
+$vhash=md5($attachment->getFileId().session_id().strtolower($file->getKey()));
 if(strcasecmp(trim($_GET['h']),$vhash)
         || !($ticket=$attachment->getTicket())
         || !$ticket->checkUserAccess($thisclient))
diff --git a/bootstrap.php b/bootstrap.php
index a82796ebd2d7ecba1eb453884502f10a193ded53..0de2d5d69bc168948d8c25c7b31f5f3241c98dcc 100644
--- a/bootstrap.php
+++ b/bootstrap.php
@@ -172,6 +172,7 @@ class Bootstrap {
 
     function loadCode() {
         #include required files
+        require(INCLUDE_DIR.'class.signal.php');
         require(INCLUDE_DIR.'class.auth.php');
         require(INCLUDE_DIR.'class.pagenate.php'); //Pagenate helper!
         require(INCLUDE_DIR.'class.log.php');
diff --git a/include/ajax.draft.php b/include/ajax.draft.php
index 0caaeeb7bb497cbd06ca7a4e83b860f16232ec0b..3cad72194b839d4b4c26b114c0902c6201d9c727 100644
--- a/include/ajax.draft.php
+++ b/include/ajax.draft.php
@@ -107,7 +107,7 @@ class DraftAjaxAPI extends AjaxController {
             return Http::response(500, 'Unable to attach image');
 
         echo JsonDataEncoder::encode(array(
-            'content_id' => 'cid:'.$f->getHash(),
+            'content_id' => 'cid:'.$f->getKey(),
             'filelink' => sprintf('image.php?h=%s', $f->getDownloadHash())
         ));
     }
diff --git a/include/class.attachment.php b/include/class.attachment.php
index a46028d83c085241461771a0b3faaf091777273d..10159f5e82842892398a9de147534e2807cc3b09 100644
--- a/include/class.attachment.php
+++ b/include/class.attachment.php
@@ -86,7 +86,7 @@ class Attachment {
     function getIdByFileHash($hash, $tid=0) {
         $sql='SELECT attach_id FROM '.TICKET_ATTACHMENT_TABLE.' a '
             .' INNER JOIN '.FILE_TABLE.' f ON(f.id=a.file_id) '
-            .' WHERE f.hash='.db_input($hash);
+            .' WHERE f.`key`='.db_input($hash);
         if($tid)
             $sql.=' AND a.ticket_id='.db_input($tid);
 
@@ -157,7 +157,7 @@ class GenericAttachments {
     function _getList($separate=false, $inlines=false) {
         if(!isset($this->attachments)) {
             $this->attachments = array();
-            $sql='SELECT f.id, f.size, f.hash, f.name, a.inline '
+            $sql='SELECT f.id, f.size, f.`key`, f.name, a.inline '
                 .' FROM '.FILE_TABLE.' f '
                 .' INNER JOIN '.ATTACHMENT_TABLE.' a ON(f.id=a.file_id) '
                 .' WHERE a.`type`='.db_input($this->getType())
@@ -171,7 +171,7 @@ class GenericAttachments {
         $attachments = array();
         foreach ($this->attachments as $a) {
             if ($a['inline'] != $separate || $a['inline'] == $inlines) {
-                $a['key'] = md5($a['id'].session_id().$a['hash']);
+                $a['key'] = md5($a['id'].session_id().$a['key']);
                 $a['file_id'] = $a['id'];
                 $attachments[] = $a;
             }
diff --git a/include/class.config.php b/include/class.config.php
index f5c7077d7dcbe07cddca31d10e83d1e63a7cb32e..54928de8f28a5e49934d516df8cda0bd5b52e2a2 100644
--- a/include/class.config.php
+++ b/include/class.config.php
@@ -149,6 +149,7 @@ class OsticketConfig extends Config {
         'name_format' =>        'full', # First Last
         'auto_claim_tickets'=>  true,
         'system_language' =>    'en_US',
+        'default_storage_bk' => 'D',
     );
 
     function OsticketConfig($section=null) {
@@ -725,6 +726,10 @@ class OsticketConfig extends Config {
         return $this->get('upload_dir');
     }
 
+    function getDefaultStorageBackendChar() {
+        return $this->get('default_storage_bk');
+    }
+
     function getVar($name) {
         return $this->get($name);
     }
@@ -855,6 +860,9 @@ class OsticketConfig extends Config {
         if(!Validator::process($f, $vars, $errors) || $errors)
             return false;
 
+        if (isset($vars['default_storage_bk']))
+            $this->update('default_storage_bk', $vars['default_storage_bk']);
+
         return $this->updateAll(array(
             'random_ticket_ids'=>$vars['random_ticket_ids'],
             'default_priority_id'=>$vars['default_priority_id'],
diff --git a/include/class.cron.php b/include/class.cron.php
index 2dcfc1b4ba544dbedbca90ce76895e70f288828b..06d992cdcea53dcca5f02891014be3c6ce27a37a 100644
--- a/include/class.cron.php
+++ b/include/class.cron.php
@@ -102,7 +102,7 @@ class Cron {
         self::PurgeDrafts();
         self::MaybeOptimizeTables();
 
-        Signal::send('cron');
+        Signal::send('cron', null);
     }
 }
 ?>
diff --git a/include/class.error.php b/include/class.error.php
index ed5bf7e4c3d8d0384c63f0d8a4dbaa7af8b543a3..602304f763c5166b75b854c43597a893f6d42220 100644
--- a/include/class.error.php
+++ b/include/class.error.php
@@ -17,39 +17,32 @@
     vim: expandtab sw=4 ts=4 sts=4:
 **********************************************************************/
 
-class Error /* extends Exception */ {
-    var $title = '';
+class Error extends Exception {
+    static $title = '';
+    static $sendAlert = true;
 
-    function Error($message) {
-        call_user_func_array(array($this,'__construct'), func_get_args());
-    }
     function __construct($message) {
         global $ost;
 
         $message = str_replace(ROOT_DIR, '(root)/', $message);
 
         if ($ost->getConfig()->getLogLevel() == 3)
-            $message .= "\n\n" . $this->formatBacktrace(debug_backtrace());
+            $message .= "\n\n" . $this->getBacktrace();
 
-        $ost->logError($this->getTitle(), $message);
+        $ost->logError($this->getTitle(), $message, static::$sendAlert);
     }
 
     function getTitle() {
-        return get_class($this) . ": {$this->title}";
+        return get_class($this) . ': ' . static::$title;
     }
 
-    function formatBacktrace($bt) {
-        $buffer = array();
-        foreach ($bt as $i=>$frame)
-            $buffer[] = sprintf("#%d %s%s%s at [%s:%d]", $i,
-                $frame['class'], $frame['type'], $frame['function'],
-                str_replace(ROOT_DIR, '', $frame['file']), $frame['line']);
-        return implode("\n", $buffer);
+    function getBacktrace() {
+        return str_replace(ROOT_DIR, '(root)/', $this->getTraceAsString());
     }
 }
 
 class InitialDataError extends Error {
-    var $title = 'Problem with install initial data';
+    static $title = 'Problem with install initial data';
 }
 
 function raise_error($message, $class=false) {
@@ -57,4 +50,9 @@ function raise_error($message, $class=false) {
     new $class($message);
 }
 
+// File storage backend exceptions
+class IOException extends Error {
+    static $title = 'Unable to read resource content';
+}
+
 ?>
diff --git a/include/class.file.php b/include/class.file.php
index d3f8393b6be57498183d555130b7b984d88aaaf5..9c8eb9c53da2a4c450421c4e8261c1efcc511311 100644
--- a/include/class.file.php
+++ b/include/class.file.php
@@ -11,6 +11,8 @@
 
     vim: expandtab sw=4 ts=4 sts=4:
 **********************************************************************/
+require_once(INCLUDE_DIR.'class.signal.php');
+require_once(INCLUDE_DIR.'class.error.php');
 
 class AttachmentFile {
 
@@ -27,7 +29,7 @@ class AttachmentFile {
         if(!$id && !($id=$this->getId()))
             return false;
 
-        $sql='SELECT id, f.type, size, name, hash, f.created, '
+        $sql='SELECT id, f.type, size, name, `key`, signature, ft, bk, f.created, '
             .' count(DISTINCT a.object_id) as canned, count(DISTINCT t.ticket_id) as tickets '
             .' FROM '.FILE_TABLE.' f '
             .' LEFT JOIN '.ATTACHMENT_TABLE.' a ON(a.file_id=f.id) '
@@ -75,6 +77,10 @@ class AttachmentFile {
         return $this->ht['type'];
     }
 
+    function getBackend() {
+        return $this->ht['bk'];
+    }
+
     function getMime() {
         return $this->getType();
     }
@@ -87,8 +93,14 @@ class AttachmentFile {
         return $this->ht['name'];
     }
 
-    function getHash() {
-        return $this->ht['hash'];
+    function getKey() {
+        return $this->ht['key'];
+    }
+
+    function getSignature() {
+        $sig = $this->ht['signature'];
+        if (!$sig) return $this->getKey();
+        return $sig;
     }
 
     function lastModified() {
@@ -96,29 +108,41 @@ class AttachmentFile {
     }
 
     /**
-     * Retrieve a hash that can be sent to scp/file.php?h= in order to
+     * Retrieve a signature that can be sent to scp/file.php?h= in order to
      * download this file
      */
     function getDownloadHash() {
-        return strtolower($this->getHash() . md5($this->getId().session_id().$this->getHash()));
+        return strtolower($this->getKey() . md5($this->getId().session_id().$this->getKey()));
     }
 
     function open() {
-        return new AttachmentChunkedData($this->id);
+        return FileStorageBackend::getInstance($this);
     }
 
-    function sendData() {
+    function sendData($redirect=true, $disposition='inline') {
+        $bk = $this->open();
+        if ($redirect && $bk->sendRedirectUrl($disposition))
+            return;
+
         @ini_set('zlib.output_compression', 'Off');
-        $file = $this->open();
-        while ($chunk = $file->read())
-            echo $chunk;
+        try {
+            $bk->passthru();
+        }
+        catch (IOException $ex) {
+            Http::response(404, 'File not found');
+        }
     }
 
     function getData() {
         # XXX: This is horrible, and is subject to php's memory
         #      restrictions, etc. Don't use this function!
         ob_start();
-        $this->sendData();
+        try {
+            $this->sendData(false);
+        }
+        catch (IOException $ex) {
+            Http::response(404, 'File not found');
+        }
         $data = &ob_get_contents();
         ob_end_clean();
         return $data;
@@ -130,14 +154,14 @@ class AttachmentFile {
         if(!db_query($sql) || !db_affected_rows())
             return false;
 
-        //Delete file data.
-        AttachmentChunkedData::deleteOrphans();
+        if ($bk = $this->open())
+            $bk->unlink();
 
         return true;
     }
 
     function makeCacheable($ttl=86400) {
-        Http::cacheable($this->getHash(), $this->lastModified(), $ttl);
+        Http::cacheable($this->getSignature(), $this->lastModified(), $ttl);
     }
 
     function display($scale=false) {
@@ -189,25 +213,64 @@ class AttachmentFile {
 
         header('Content-Transfer-Encoding: binary');
         header('Content-Length: '.$this->getSize());
-        $this->sendData();
+        $this->sendData(true, 'attachment');
         exit();
     }
 
+    function _getKeyAndHash($data=false, $file=false) {
+        if ($file) {
+            $sha1 = base64_encode(sha1_file($data, true));
+            $md5 = base64_encode(md5_file($data, true));
+        }
+        else {
+            $sha1 = base64_encode(sha1($data, true));
+            $md5 = base64_encode(md5($data, true));
+        }
+
+        // Use 5 chars from the microtime() prefix and 27 chars from the
+        // sha1 hash. This should make a sufficiently strong unique key for
+        // file content. In the event there is a sha1 collision for data, it
+        // should be unlikely that there will be a collision for the
+        // microtime hash coincidently.  Remove =, change + and / to chars
+        // better suited for URLs and filesystem paths
+        $prefix = base64_encode(sha1(microtime(), true));
+        $key = str_replace(
+            array('=','+','/'),
+            array('','-','_'),
+            substr($prefix, 0, 5) . $sha1);
+
+        // The hash is a 32-char value where the first half is from the last
+        // 16 chars from the SHA1 hash and the last 16 chars are the last 16
+        // chars from the MD5 hash. This should provide for better
+        // resiliance against hash collisions and attacks against any one
+        // hash algorithm. Since we're using base64 encoding, with 6-bits
+        // per char, we should have a total hash strength of 192 bits.
+        $hash = str_replace(
+            array('=','+','/'),
+            array('','-','_'),
+            substr($sha1, 0, 16) . substr($md5, 0, 16));
+
+        return array($key, $hash);
+    }
+
     /* Function assumes the files types have been validated */
     function upload($file, $ft='T') {
 
         if(!$file['name'] || $file['error'] || !is_uploaded_file($file['tmp_name']))
             return false;
 
+        list($key, $sig) = self::_getKeyAndHash($file['tmp_name'], true);
+
         $info=array('type'=>$file['type'],
                     'filetype'=>$ft,
                     'size'=>$file['size'],
                     'name'=>$file['name'],
-                    'hash'=>MD5(MD5_FILE($file['tmp_name']).time()),
-                    'data'=>file_get_contents($file['tmp_name'])
+                    'key'=>$key,
+                    'signature'=>$sig,
+                    'tmp_name'=>$file['tmp_name'],
                     );
 
-        return AttachmentFile::save($info);
+        return AttachmentFile::save($info, $ft);
     }
 
     function uploadLogo($file, &$error, $aspect_ratio=3) {
@@ -241,42 +304,144 @@ class AttachmentFile {
         return false;
     }
 
-    function save($file) {
+    function save(&$file, $ft=false) {
 
-        if (is_callable($file['data']))
-            $file['data'] = $file['data']();
+        if (isset($file['data'])) {
+            // Allow a callback function to delay or avoid reading or
+            // fetching ihe file contents
+            if (is_callable($file['data']))
+                $file['data'] = $file['data']();
 
-        if(!$file['hash'])
-            $file['hash'] = MD5(MD5($file['data']).time());
+            list($key, $file['signature'])
+                = self::_getKeyAndHash($file['data']);
+            if (!$file['key'])
+                $file['key'] = $key;
 
-        if(!$file['size'])
-            $file['size'] = strlen($file['data']);
+            if (!isset($file['size']))
+                $file['size'] = strlen($file['data']);
+        }
+
+        // Check and see if the file is already on record
+        $sql = 'SELECT id FROM '.FILE_TABLE
+            .' WHERE signature='.db_input($file['signature'])
+            .' AND size='.db_input($file['size']);
+
+        // If the record exists in the database already, a file with the
+        // same hash and size is already on file -- just return its ID
+        if ($id = db_result(db_query($sql)))
+            return $id;
 
         $sql='INSERT INTO '.FILE_TABLE.' SET created=NOW() '
             .',type='.db_input(strtolower($file['type']))
             .',size='.db_input($file['size'])
             .',name='.db_input($file['name'])
-            .',hash='.db_input($file['hash']);
+            .',`key`='.db_input($file['key'])
+            .',signature='.db_input($file['signature']);
+
+        if (!(db_query($sql) && ($id = db_insert_id())))
+            return false;
+
+        if (!($f = AttachmentFile::lookup($id)))
+            return false;
+
+        // Note that this is preferred over $f->open() because the file does
+        // not have a valid backend configured yet. ::getBackendForFile()
+        // will consider the system configuration for storing the file
+        $bk = self::getBackendForFile($f);
+        if (isset($file['tmp_name'])) {
+            if (!$bk->upload($file['tmp_name']))
+                return false;
+        }
+        elseif (!$bk->write($file['data'])) {
+            // XXX: Fallthrough to default backend if different?
+            return false;
+        }
 
         # XXX: ft does not exists during the upgrade when attachments are
-        #      migrated!
-        if(isset($file['filetype']))
-            $sql.=',ft='.db_input($file['filetype']);
+        #      migrated! Neither does `bk`
+        if ($ft) {
+            $sql = 'UPDATE '.FILE_TABLE.' SET bk='
+                .db_input($bk->getBkChar())
+                .', ft='.db_input($ft)
+                .' WHERE id='.db_input($f->getId());
+            db_query($sql);
+        }
 
-        if (!(db_query($sql) && ($id=db_insert_id())))
+        return $f->getId();
+    }
+
+    /**
+     * Migrate this file from the current backend to the backend specified.
+     *
+     * Parameters:
+     * $bk - (string) type char of the target storage backend. Use
+     *      AttachmentStorageBackend::allRegistered() to get a list of type
+     *      chars and associated class names
+     *
+     * Returns:
+     * True if the migration was successful and false otherwise.
+     */
+    function migrate($bk) {
+        $before = hash_init('sha1');
+        $after = hash_init('sha1');
+
+        // Copy the file to the new backend and hash the contents
+        $target = AttachmentStorageBackend::lookup($bk, $this->ht);
+        $source = $this->open();
+        // TODO: Make this resumable so that if the file cannot be migrated
+        //      in the max_execution_time, the migration can be continued
+        //      the next time the cron runs
+        while ($block = $source->read()) {
+            hash_update($before, $block);
+            $target->write($block);
+        }
+
+        // Verify that the hash of the target file matches the hash of the
+        // source file
+        $target = AttachmentStorageBackend::lookup($bk, $this->ht);
+        while ($block = $target->read())
+            hash_update($after, $block);
+
+        if (hash_final($before) != hash_final($after)) {
+            $target->unlink();
             return false;
+        }
 
-        $data = new AttachmentChunkedData($id);
-        if (!$data->write($file['data']))
+        $sql = 'UPDATE '.FILE_TABLE.' SET bk='
+            .db_input($target->getBkChar())
+            .' WHERE id='.db_input($this->getId());
+        if (!db_query($sql) || db_affected_rows()!=1)
             return false;
 
-        return $id;
+        return $source->unlink();
+    }
+
+    /**
+     * Considers the system's configuration for file storage selection based
+     * on the file information and purpose (FAQ attachment, image, etc).
+     *
+     * Parameters:
+     * $file - (hasharray) file information which would be passed to
+     * ::save() for instance.
+     *
+     * Returns:
+     * Instance<FileStorageBackend> backend selected based on the file
+     * received.
+     */
+    static function getBackendForFile($file) {
+        global $cfg;
+
+        if (!$cfg)
+            return new AttachmentChunkedData($file);
+
+        $char = $cfg->getDefaultStorageBackendChar();
+        return FileStorageBackend::lookup($char, $file);
     }
 
     /* Static functions */
     function getIdByHash($hash) {
 
-        $sql='SELECT id FROM '.FILE_TABLE.' WHERE hash='.db_input($hash);
+        $sql='SELECT id FROM '.FILE_TABLE.' WHERE `key`='.db_input($hash);
         if(($res=db_query($sql)) && db_num_rows($res))
             list($id)=db_fetch_row($res);
 
@@ -352,19 +517,23 @@ class AttachmentFile {
      */
     /* static */ function deleteOrphans() {
 
-        $sql = 'DELETE FROM '.FILE_TABLE.' WHERE id NOT IN ('
+        // XXX: Allow plugins to define filetypes which do not represent
+        //      files attached to tickets or other things in the attachment
+        //      table and are not logos
+        $sql = 'SELECT id FROM '.FILE_TABLE.' WHERE id NOT IN ('
                 .'SELECT file_id FROM '.TICKET_ATTACHMENT_TABLE
                 .' UNION '
                 .'SELECT file_id FROM '.ATTACHMENT_TABLE
             .") AND `ft` = 'T'";
 
-        db_query($sql);
+        if (!($res = db_query($sql)))
+            return false;
 
-        //Delete orphaned chuncked data!
-        AttachmentChunkedData::deleteOrphans();
+        while (list($id) = db_fetch_row($res))
+            if (($file = self::lookup($id)) && !$file->delete())
+                break;
 
         return true;
-
     }
 
     /* static */
@@ -379,22 +548,138 @@ class AttachmentFile {
     }
 }
 
+class FileStorageBackend {
+    var $meta;
+    static $desc = false;
+    static $registry;
+
+    /**
+     * All storage backends should call this function during the request
+     * bootstrap phase.
+     */
+    static function register($typechar, $class) {
+        self::$registry[$typechar] = $class;
+    }
+
+    static function allRegistered() {
+        return self::$registry;
+    }
+
+    /**
+     * Retrieves the type char registered for this storage backend's class.
+     * Null is returned if the backend is not properly registered.
+     */
+    function getBkChar() {
+        foreach (self::$registry as $tc=>$class)
+            if ($this instanceof $class)
+                return $tc;
+    }
+
+    static function lookup($type, $file=null) {
+        if (!isset(self::$registry[$type]))
+            throw new Exception("No such backend registered");
+
+        $class = self::$registry[$type];
+        return new $class($file);
+    }
+
+    static function getInstance($file) {
+        if (!isset(self::$registry[$file->getBackend()]))
+            throw new Exception("No such backend registered");
+
+        $class = self::$registry[$file->getBackend()];
+        return new $class($file);
+    }
+
+    /**
+     * Create an instance of the storage backend linking the related file.
+     * Information about the file metadata is accessible via the received
+     * filed object.
+     */
+    function __construct($meta) {
+        $this->meta = $meta;
+    }
+
+    /**
+     * Commit file to the storage backend. This method is used if the
+     * backend cannot support writing a file directly. Otherwise, the
+     * ::upload($file) method is preferred.
+     *
+     * Parameters:
+     * $data - (string|binary) file contents to be written to the backend
+     */
+    function write($data) {
+        return false;
+    }
+
+    /**
+     * Upload a file to the backend. This method is preferred over ::write()
+     * for files which are uploaded or are otherwise available out of
+     * memory. The backend is encouraged to avoid reading the entire
+     * contents into memory.
+     */
+    function upload($filepath) {
+        return $this->write(file_get_contents($filepath));
+    }
+
+    /**
+     * Returns data from the backend, optionally returning only the number
+     * of bytes indicated at the specified offset. If the data is available
+     * in chunks, one chunk may be returned at a time. The backend should
+     * return boolean false when no more chunks are available.
+     */
+    function read($amount=0, $offset=0) {
+        return false;
+    }
+
+    /**
+     * Convenience method to send all the file to standard output
+     */
+    function passthru() {
+        while ($block = $this->read())
+            echo $block;
+    }
+
+    /**
+     * If the data is not stored or not available locally, a redirect
+     * response can be sent to the user agent indicating the actual HTTP
+     * location of the data.
+     *
+     * If the data is available locally, this method should return boolean
+     * false to indicate that the read() method should be used to retrieve
+     * the data and broker it to the user agent.
+     */
+    function sendRedirectUrl($disposition='inline') {
+        return false;
+    }
+
+    /**
+     * Requests the backend to remove the file contents.
+     */
+    function unlink() {
+        return false;
+    }
+}
+
+
 /**
- * Attachments stored in the database are cut into 256kB chunks and stored
+ * Attachments stored in the database are cut into 500kB chunks and stored
  * in the FILE_CHUNK_TABLE to overcome the max_allowed_packet limitation of
  * LOB fields in the MySQL database
  */
 define('CHUNK_SIZE', 500*1024); # Beware if you change this...
-class AttachmentChunkedData {
-    function AttachmentChunkedData($file) {
-        $this->_file = $file;
+class AttachmentChunkedData extends FileStorageBackend {
+    static $desc = "In the database";
+
+    function __construct($file) {
+        $this->file = $file;
         $this->_pos = 0;
     }
 
     function length() {
         list($length) = db_fetch_row(db_query(
              'SELECT SUM(LENGTH(filedata)) FROM '.FILE_CHUNK_TABLE
-            .' WHERE file_id='.db_input($this->_file)));
+            .' WHERE file_id='.db_input($this->file->getId())));
         return $length;
     }
 
@@ -402,7 +687,7 @@ class AttachmentChunkedData {
         # Read requested length of data from attachment chunks
         list($buffer) = @db_fetch_row(db_query(
             'SELECT filedata FROM '.FILE_CHUNK_TABLE.' WHERE file_id='
-            .db_input($this->_file).' AND chunk_id='.$this->_pos++));
+            .db_input($this->file->getId()).' AND chunk_id='.$this->_pos++));
         return $buffer;
     }
 
@@ -413,7 +698,7 @@ class AttachmentChunkedData {
             if (!$block) break;
             if (!db_query('REPLACE INTO '.FILE_CHUNK_TABLE
                     .' SET filedata=0x'.bin2hex($block).', file_id='
-                    .db_input($this->_file).', chunk_id='.db_input($this->_pos++)))
+                    .db_input($this->file->getId()).', chunk_id='.db_input($this->_pos++)))
                 return false;
             $offset += strlen($block);
         }
@@ -421,20 +706,12 @@ class AttachmentChunkedData {
         return $this->_pos;
     }
 
-    function deleteOrphans() {
-        $deleted = 0;
-        $sql = 'SELECT c.file_id, c.chunk_id FROM '.FILE_CHUNK_TABLE.' c '
-             . ' LEFT JOIN '.FILE_TABLE.' f ON(f.id=c.file_id) '
-             . ' WHERE f.id IS NULL';
-
-        $res = db_query($sql);
-        while (list($file_id, $chunk_id) = db_fetch_row($res)) {
-            db_query('DELETE FROM '.FILE_CHUNK_TABLE
-                .' WHERE file_id='.db_input($file_id)
-                .' AND chunk_id='.db_input($chunk_id));
-            $deleted += db_affected_rows();
-        }
-        return $deleted;
+    function unlink() {
+        db_query('DELETE FROM '.FILE_CHUNK_TABLE
+            .' WHERE file_id='.db_input($this->file->getId()));
+        return db_affected_rows() > 0;
     }
 }
+FileStorageBackend::register('D', 'AttachmentChunkedData');
+
 ?>
diff --git a/include/class.mailer.php b/include/class.mailer.php
index 21a7d157d68a28191b88e798dade0633d3c15725..f6b83784edd727361e927d195a1c65702759a26d 100644
--- a/include/class.mailer.php
+++ b/include/class.mailer.php
@@ -166,7 +166,7 @@ class Mailer {
                         return $match[0];
                     $mime->addHTMLImage($file->getData(),
                         $file->getType(), $file->getName(), false,
-                        $file->getHash().'@'.$domain);
+                        $file->getKey().'@'.$domain);
                     // Don't re-attach the image below
                     unset($self->attachments[$file->getId()]);
                     return $match[0].'@'.$domain;
diff --git a/include/class.plugin.php b/include/class.plugin.php
index 29dddf0f982c9549e5300e40a462e6906de83e08..a9ed46ffd2311010865fa2c4cccfaca94d6d1de9 100644
--- a/include/class.plugin.php
+++ b/include/class.plugin.php
@@ -329,16 +329,18 @@ class Plugin {
      * configuration for the plugin is also removed. If the plugin is
      * reinstalled, it will have to be reconfigured.
      */
-    function uninstall() {
-        if ($this->pre_uninstall() === false)
+    function uninstall(&$errors) {
+        if ($this->pre_uninstall($errors) === false)
             return false;
 
         $sql = 'DELETE FROM '.PLUGIN_TABLE
             .' WHERE id='.db_input($this->getId());
         PluginManager::clearCache();
-        if (db_query($sql) && db_affected_rows())
-            return $this->getConfig()->purge();
-        return false;
+        if (!db_query($sql) || !db_affected_rows())
+            return false;
+
+        $this->getConfig()->purge();
+        return true;
     }
 
     /**
@@ -346,9 +348,8 @@ class Plugin {
      *
      * Hook function to veto the uninstallation request. Return boolean
      * FALSE if the uninstall operation should be aborted.
-     * TODO: Recommend a location to stash an error message if aborting
      */
-    function pre_uninstall() {
+    function pre_uninstall(&$errors) {
         return true;
     }
 
diff --git a/include/class.thread.php b/include/class.thread.php
index 6fa0d9c13cc74e85dd8e57dce0c4f90411c1166f..b7a0d8eaf0df77ac3cf8ff9cff6a7fb4370c56cc 100644
--- a/include/class.thread.php
+++ b/include/class.thread.php
@@ -457,13 +457,13 @@ Class ThreadEntry {
         return $uploaded;
     }
 
-    function importAttachments($attachments) {
+    function importAttachments(&$attachments) {
 
         if(!$attachments || !is_array($attachments))
             return null;
 
         $files = array();
-        foreach($attachments as  $attachment)
+        foreach($attachments as &$attachment)
             if(($id=$this->importAttachment($attachment)))
                 $files[] = $id;
 
@@ -471,7 +471,7 @@ Class ThreadEntry {
     }
 
     /* Emailed & API attachments handler */
-    function importAttachment($attachment) {
+    function importAttachment(&$attachment) {
 
         if(!$attachment || !is_array($attachment))
             return null;
@@ -493,7 +493,7 @@ Class ThreadEntry {
     Save attachment to the DB.
     @file is a mixed var - can be ID or file hashtable.
     */
-    function saveAttachment($file) {
+    function saveAttachment(&$file) {
 
         if(!($fileId=is_numeric($file)?$file:AttachmentFile::save($file)))
             return 0;
@@ -528,7 +528,7 @@ Class ThreadEntry {
             return $this->attachments;
 
         //XXX: inner join the file table instead?
-        $sql='SELECT a.attach_id, f.id as file_id, f.size, f.hash as file_hash, f.name '
+        $sql='SELECT a.attach_id, f.id as file_id, f.size, lower(f.`key`) as file_hash, f.name '
             .' FROM '.FILE_TABLE.' f '
             .' INNER JOIN '.TICKET_ATTACHMENT_TABLE.' a ON(f.id=a.file_id) '
             .' WHERE a.ticket_id='.db_input($this->getTicketId())
@@ -850,21 +850,6 @@ Class ThreadEntry {
             if((list($msg) = explode($tag, $vars['body'], 2)) && trim($msg))
                 $vars['body'] = $msg;
 
-        if (isset($vars['attachments'])) {
-            foreach ($vars['attachments'] as &$a) {
-                // Change <img src="cid:"> inside the message to point to
-                // a unique hash-code for the attachment. Since the
-                // content-id will be discarded, only the unique hash-code
-                // will be available to retrieve the image later
-                if ($a['cid']) {
-                    $a['hash'] = Misc::randCode(32);
-                    $vars['body'] = str_replace('src="cid:'.$a['cid'].'"',
-                        'src="cid:'.$a['hash'].'"', $vars['body']);
-                }
-            }
-            unset($a);
-        }
-
         if (!$cfg->isHtmlThreadEnabled()) {
             // Data in the database is assumed to be HTML, change special
             // plain text XML characters
@@ -882,12 +867,16 @@ Class ThreadEntry {
             .' ,thread_type='.db_input($vars['type'])
             .' ,ticket_id='.db_input($vars['ticketId'])
             .' ,title='.db_input(Format::sanitize($vars['title'], true))
-            .' ,body='.db_input($vars['body'])
             .' ,staff_id='.db_input($vars['staffId'])
             .' ,user_id='.db_input($vars['userId'])
             .' ,poster='.db_input($poster)
             .' ,source='.db_input($vars['source']);
 
+        if (!isset($vars['attachments']))
+            // Otherwise, body will be configured in a block below (after
+            // inline attachments are saved and updated in the database)
+            $sql.=' ,body='.db_input($vars['body']);
+
         if(isset($vars['pid']))
             $sql.=' ,pid='.db_input($vars['pid']);
         // Check if 'reply_to' is in the $vars as the previous ThreadEntry
@@ -910,14 +899,30 @@ Class ThreadEntry {
         if($vars['files']) //expects well formatted and VALIDATED files array.
             $entry->uploadFiles($vars['files']);
 
-        //Emailed or API attachments
-        if($vars['attachments'])
-            $entry->importAttachments($vars['attachments']);
-
         //Canned attachments...
         if($vars['cannedattachments'] && is_array($vars['cannedattachments']))
             $entry->saveAttachments($vars['cannedattachments']);
 
+        //Emailed or API attachments
+        if (isset($vars['attachments']) && $vars['attachments']) {
+            $entry->importAttachments($vars['attachments']);
+            foreach ($vars['attachments'] as &$a) {
+                // Change <img src="cid:"> inside the message to point to
+                // a unique hash-code for the attachment. Since the
+                // content-id will be discarded, only the unique hash-code
+                // will be available to retrieve the image later
+                if ($a['cid'] && $a['key']) {
+                    $vars['body'] = str_replace('src="cid:'.$a['cid'].'"',
+                        'src="cid:'.$a['key'].'"', $vars['body']);
+                }
+            }
+            unset($a);
+            $sql = 'UPDATE '.TICKET_THREAD_TABLE.' SET body='.db_input($vars['body'])
+                .' WHERE `id`='.db_input($entry->getId());
+            if (!db_query($sql) || !db_affected_rows())
+                return false;
+        }
+
         // Email message id (required for all thread posts)
         if (!isset($vars['mid']))
             $vars['mid'] = sprintf('<%s@%s>', Misc::randCode(24),
diff --git a/include/class.yaml.php b/include/class.yaml.php
index fc340d70abc0153b3e3f8071b8981c3da17f5cbc..63ceb37543d3fe4044172e1a7775c84e6734feb8 100644
--- a/include/class.yaml.php
+++ b/include/class.yaml.php
@@ -37,6 +37,6 @@ class YamlDataParser {
 }
 
 class YamlParserError extends Error {
-    var $title = 'Error parsing YAML document';
+    static $title = 'Error parsing YAML document';
 }
 ?>
diff --git a/include/i18n/en_US/file.yaml b/include/i18n/en_US/file.yaml
index 19f2e35a5a04618e8b08a015961581f82634937c..b6b9ac906fe09b8a61aa32c1c07f46138cc811ee 100644
--- a/include/i18n/en_US/file.yaml
+++ b/include/i18n/en_US/file.yaml
@@ -9,7 +9,7 @@
 # bill be cleaned up shortly after installation (by the autocron).
 #
 ---
-- hash: b56944cb4722cc5cda9d1e23a3ea7fbc
+- key: b56944cb4722cc5cda9d1e23a3ea7fbc
   name: powered-by-osticket.png
   type: image/png
   encoding: base64
@@ -105,7 +105,7 @@
       NQLJLj4m+f1lr/Kfzeo1TMm+prKzxxgz2AeQEd49EE0PDxwVGTx+lDbX4G94cZ021zwTueT9+79q
       vjLYs5AR3j0Seq638S2PileedN26W3OuWb8rz9FmsJfg/wBsHf7rZZG4/wAAAABJRU5ErkJggg==
 
-- hash: 6fe1efdea357534d238b86e7860a7c5a
+- key: 6fe1efdea357534d238b86e7860a7c5a
   name: kangaroo.png
   type: image/png
   encoding: base64
diff --git a/include/staff/plugins.inc.php b/include/staff/plugins.inc.php
index 4b9c5bccc3d83a0474fc4e953b8dfb5c161d0ef9..a25ccc095a1c11a1544d2c24d3666d185ee0ca70 100644
--- a/include/staff/plugins.inc.php
+++ b/include/staff/plugins.inc.php
@@ -51,7 +51,7 @@ foreach ($ost->plugins->allInstalled() as $p) {
             <a id="selectNone" href="#ckb">None</a>&nbsp;&nbsp;
             <a id="selectToggle" href="#ckb">Toggle</a>&nbsp;&nbsp;
             <?php }else{
-                echo 'No extra forms defined yet &mdash; add one!';
+                echo 'No plugins installed yet &mdash; <a href="?a=add">add one</a>!';
             } ?>
         </td>
      </tr>
diff --git a/include/staff/settings-tickets.inc.php b/include/staff/settings-tickets.inc.php
index ce7489d19ddd15863d7849d3c33930ee93029712..b25ad9d597c4daed162e69aa0df78a5f3ef7e32a 100644
--- a/include/staff/settings-tickets.inc.php
+++ b/include/staff/settings-tickets.inc.php
@@ -240,6 +240,20 @@ if(!($maxfileuploads=ini_get('max_file_uploads')))
                 <input type="checkbox" name="email_attachments" <?php echo $config['email_attachments']?'checked="checked"':''; ?> >Email attachments to the user
             </td>
         </tr>
+        <?php if (($bks = FileStorageBackend::allRegistered())
+                && count($bks) > 1) { ?>
+        <tr>
+            <td width="180">Store Attachments:</td>
+            <td><select name="default_storage_bk"><?php
+                foreach ($bks as $char=>$class) {
+                    $selected = $config['default_storage_bk'] == $char
+                        ? 'selected="selected"' : '';
+                    ?><option <?php echo $selected; ?> value="<?php echo $char; ?>"
+                    ><?php echo $class::$desc; ?></option><?php
+                } ?>
+            </td>
+        </tr>
+        <?php } ?>
         <tr>
             <th colspan="2">
                 <em><strong>Accepted File Types</strong>: Limit the type of files users are allowed to submit.
diff --git a/include/staff/ticket-open.inc.php b/include/staff/ticket-open.inc.php
index 7fe18f3820d783b5fff99a0e154dc131a174f245..a1b180aae2c0c8d47a8819d29595c83d2b6a0489 100644
--- a/include/staff/ticket-open.inc.php
+++ b/include/staff/ticket-open.inc.php
@@ -278,7 +278,7 @@ $info=Format::htmlchars(($errors && $_POST)?$_POST:$info);
                             if($info['cannedattachments']) {
                                 foreach($info['cannedattachments'] as $k=>$id) {
                                     if(!($file=AttachmentFile::lookup($id))) continue;
-                                    $hash=$file->getHash().md5($file->getId().session_id().$file->getHash());
+                                    $hash=$file->getKey().md5($file->getId().session_id().$file->getKey());
                                     echo sprintf('<label><input type="checkbox" name="cannedattachments[]"
                                             id="f%d" value="%d" checked="checked"
                                             <a href="file.php?h=%s">%s</a>&nbsp;&nbsp;</label>&nbsp;',
diff --git a/include/upgrader/streams/core.sig b/include/upgrader/streams/core.sig
index ac8e6b7747418718552fa3ecf14189a652bf9901..6dad7bd6c28059aac49c966dd472d51ddcf6e66a 100644
--- a/include/upgrader/streams/core.sig
+++ b/include/upgrader/streams/core.sig
@@ -1 +1 @@
-934954de8914d9bd2bb8343e805340ae
+f1ccd3bb620e314b0ae1dbd0a1a99177
diff --git a/include/upgrader/streams/core/934954de-f1ccd3bb.patch.sql b/include/upgrader/streams/core/934954de-f1ccd3bb.patch.sql
new file mode 100644
index 0000000000000000000000000000000000000000..c6bb713e7cafd57b4ccc58bd8df41ddc23d9fe0c
--- /dev/null
+++ b/include/upgrader/streams/core/934954de-f1ccd3bb.patch.sql
@@ -0,0 +1,23 @@
+/**
+ * @version v1.8.1
+ * @signature f1ccd3bb620e314b0ae1dbd0a1a99177
+ * @title Pluggable Storage Backends
+ *
+ * This patch will allow attachments to be stored outside the database (like
+ * on the filesystem)
+ */
+
+ALTER TABLE `%TABLE_PREFIX%file`
+    ADD `bk` CHAR(1) NOT NULL DEFAULT 'D' AFTER `ft`,
+    -- RFC 4288, Section 4.2 declares max MIMEType at 255 ascii chars
+    CHANGE `type` `type` varchar(255) collate ascii_general_ci NOT NULL default '',
+    CHANGE `size` `size` BIGINT(20) NOT NULL DEFAULT 0,
+    CHANGE `hash` `key` VARCHAR(86) COLLATE ascii_general_ci,
+    ADD `signature` VARCHAR(86) COLLATE ascii_bin AFTER `key`,
+    ADD `attrs` VARCHAR(255) AFTER `name`,
+    ADD INDEX (`signature`);
+
+-- Finished with patch
+UPDATE `%TABLE_PREFIX%config`
+    SET `value` = 'f1ccd3bb620e314b0ae1dbd0a1a99177'
+    WHERE `key` = 'schema_signature' AND `namespace` = 'core';
diff --git a/kb/file.php b/kb/file.php
index c411002b8f1ed2f2674f7eb52528dc9355570891..21336765817fa588a82a983af5e8b52dc8da2a85 100644
--- a/kb/file.php
+++ b/kb/file.php
@@ -23,7 +23,7 @@ $h=trim($_GET['h']);
 //basic checks
 if(!$h  || strlen($h)!=64  //32*2
         || !($file=AttachmentFile::lookup(substr($h,0,32))) //first 32 is the file hash.
-        || strcasecmp(substr($h,-32),md5($file->getId().session_id().$file->getHash()))) //next 32 is file id + session hash.
+        || strcasecmp(substr($h,-32),md5($file->getId().session_id().$file->getKey()))) //next 32 is file id + session hash.
     die('Unknown or invalid file. #'.Format::htmlchars($_GET['h']));
 
 $file->download();
diff --git a/scp/attachment.php b/scp/attachment.php
index 44a49d61bd264edef8e54b1c107126a11964aeed..28b9a185b4df488674d9157a13292cefb39bbc60 100644
--- a/scp/attachment.php
+++ b/scp/attachment.php
@@ -23,7 +23,7 @@ if(!$thisstaff || !$_GET['id'] || !$_GET['h']
     die('Unknown attachment!');
 
 //Validate session access hash - we want to make sure the link is FRESH! and the user has access to the parent ticket!!
-$vhash=md5($attachment->getFileId().session_id().$file->getHash());
+$vhash=md5($attachment->getFileId().session_id().strtolower($file->getKey()));
 if(strcasecmp(trim($_GET['h']),$vhash) || !($ticket=$attachment->getTicket()) || !$ticket->checkStaffAccess($thisstaff)) die('Access Denied');
 
 //Download the file..
diff --git a/scp/file.php b/scp/file.php
index c02562eb2a2fb1fe334884f5bd7f66f99800f181..4ccc3b828c04f240de799714319781974a0353bd 100644
--- a/scp/file.php
+++ b/scp/file.php
@@ -23,7 +23,7 @@ $h=trim($_GET['h']);
 //basic checks
 if(!$h  || strlen($h)!=64  //32*2
         || !($file=AttachmentFile::lookup(substr($h,0,32))) //first 32 is the file hash.
-        || strcasecmp(substr($h,-32),md5($file->getId().session_id().$file->getHash()))) //next 32 is file id + session hash.
+        || strcasecmp(substr($h,-32),md5($file->getId().session_id().strtolower($file->getKey())))) //next 32 is file id + session hash.
     die('Unknown or invalid file. #'.Format::htmlchars($_GET['h']));
 
 $file->download();
diff --git a/setup/inc/streams/core/install-mysql.sql b/setup/inc/streams/core/install-mysql.sql
index 1dbdd3b7794c5ebd17a4ae6d8494d2642fcbcde3..4b51a63c9410e742ac716eb759a61670ef968799 100644
--- a/setup/inc/streams/core/install-mysql.sql
+++ b/setup/inc/streams/core/install-mysql.sql
@@ -317,14 +317,19 @@ DROP TABLE IF EXISTS `%TABLE_PREFIX%file`;
 CREATE TABLE `%TABLE_PREFIX%file` (
   `id` int(11) NOT NULL auto_increment,
   `ft` CHAR( 1 ) NOT NULL DEFAULT  'T',
-  `type` varchar(255) NOT NULL default '',
-  `size` varchar(25) NOT NULL default '',
-  `hash` varchar(125) NOT NULL,
+  `bk` CHAR( 1 ) NOT NULL DEFAULT  'D',
+  -- RFC 4288, Section 4.2 declares max MIMEType at 255 ascii chars
+  `type` varchar(255) collate ascii_general_ci NOT NULL default '',
+  `size` bigint(20) unsigned NOT NULL default 0,
+  `key` varchar(86) collate ascii_general_ci NOT NULL,
+  `signature` varchar(86) collate ascii_bin NOT NULL,
   `name` varchar(255) NOT NULL default '',
+  `attrs` varchar(255),
   `created` datetime NOT NULL,
   PRIMARY KEY  (`id`),
   KEY `ft` (`ft`),
-  KEY `hash` (`hash`)
+  KEY `key` (`key`),
+  KEY `signature` (`signature`)
 ) DEFAULT CHARSET=utf8;
 
 DROP TABLE IF EXISTS `%TABLE_PREFIX%file_chunk`;