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> <a id="selectToggle" href="#ckb">Toggle</a> <?php }else{ - echo 'No extra forms defined yet — add one!'; + echo 'No plugins installed yet — <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> </label> ', 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`;