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/class.file.php b/include/class.file.php index d3f8393b6be57498183d555130b7b984d88aaaf5..36212ec5d0f48562b4d934fd73c7911940c82390 100644 --- a/include/class.file.php +++ b/include/class.file.php @@ -11,6 +11,7 @@ vim: expandtab sw=4 ts=4 sts=4: **********************************************************************/ +require_once(INCLUDE_DIR.'class.signal.php'); class AttachmentFile { @@ -27,7 +28,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, hash, ft, 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 +76,10 @@ class AttachmentFile { return $this->ht['type']; } + function getBackend() { + return $this->ht['ft']; + } + function getMime() { return $this->getType(); } @@ -104,21 +109,23 @@ class AttachmentFile { } function open() { - return new AttachmentChunkedData($this->id); + return AttachmentStorageBackend::getInstance($this); } - function sendData() { + function sendData($redirect=true) { + $bk = $this->open(); + if ($redirect && $bk->sendRedirectUrl()) + return; + @ini_set('zlib.output_compression', 'Off'); - $file = $this->open(); - while ($chunk = $file->read()) - echo $chunk; + $bk->passthru(); } function getData() { # XXX: This is horrible, and is subject to php's memory # restrictions, etc. Don't use this function! ob_start(); - $this->sendData(); + $this->sendData(false); $data = &ob_get_contents(); ob_end_clean(); return $data; @@ -193,18 +200,57 @@ class AttachmentFile { 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, -16) . substr($md5, -16)); + + return array($key, $hash); + } + /* Function assumes the files types have been validated */ - function upload($file, $ft='T') { + function upload($file, $ft=false) { if(!$file['name'] || $file['error'] || !is_uploaded_file($file['tmp_name'])) return false; + list($key, $hash) = static::_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, + 'hash'=>$hash, + 'tmp_nape'=>$file['tmp_name'], ); return AttachmentFile::save($info); @@ -241,16 +287,30 @@ class AttachmentFile { return false; } - function save($file) { + function save($file, $save_bk=true) { + + 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 (is_callable($file['data'])) - $file['data'] = $file['data'](); + list($file['key'], $file['hash']) + = static::_getKeyAndHash($file['data']); - if(!$file['hash']) - $file['hash'] = MD5(MD5($file['data']).time()); + 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 hash='.db_input($file['hash']) + .' AND size='.db_input($file['size']); - if(!$file['size']) - $file['size'] = strlen($file['data']); + // 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'])) @@ -258,19 +318,83 @@ class AttachmentFile { .',name='.db_input($file['name']) .',hash='.db_input($file['hash']); + if (!(db_query($sql) && ($file['id']=db_insert_id()))) + return false; + + $bk = self::getBackendForFile($file); + if (isset($file['tmp_file'])) { + if (!$bk->upload($file['tmp_file'])) + 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']); + if ($save_bk) { + $sql .= 'UPDATE '.FILE_TABLE.' SET ft=' + .db_input(AttachmentStorageBackend::getTypeChar($bk)) + .' WHERE id='.db_input($file->getId()); + db_query($sql); + } - if (!(db_query($sql) && ($id=db_insert_id()))) + return $file->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); + print("Opening source\n"); + $source = $this->open(); + print("Copying data "); + while ($block = $source->read()) { + print("."); + hash_update($before, $block); + $target->write($block); + } + print(" Done\n"); + + // Verify that the hash of the target file matches the hash of the + // source file + print("Verifying transferred data\n"); + $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'])) + print("Updating file meta table\n"); + $sql = 'UPDATE '.FILE_TABLE.' SET ft=' + .db_input($target->getTypeChar()) + .' WHERE id='.db_input($this->getId()); + if (!db_query($sql) || db_affected_rows()!=1) return false; - return $id; + print("Unlinking source data\n"); + return $source->unlink(); + } + + static function getBackendForFile($info) { + return AttachmentStorageBackend::lookup('T', $info); } /* Static functions */ @@ -352,11 +476,14 @@ class AttachmentFile { */ /* static */ function deleteOrphans() { + // 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 = 'DELETE FROM '.FILE_TABLE.' WHERE id NOT IN (' .'SELECT file_id FROM '.TICKET_ATTACHMENT_TABLE .' UNION ' .'SELECT file_id FROM '.ATTACHMENT_TABLE - .") AND `ft` = 'T'"; + .") AND `ft` NOT IN ('L')"; db_query($sql); @@ -379,22 +506,136 @@ class AttachmentFile { } } +class AttachmentStorageBackend { + 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 getTypeChar() { + 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 static::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) { + } + + /** + * 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() { + 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 AttachmentStorageBackend { + 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 +643,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 +654,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,11 +662,17 @@ class AttachmentChunkedData { return $this->_pos; } + function unlink() { + db_query('DELETE FROM '.FILE_CHUNK_TABLE + .' WHERE file_id='.db_input($this->file->getId())); + return db_affected_rows() > 0; + } + 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'; + . ' WHERE f.id IS NULL AND f.`type`=\'T\''; $res = db_query($sql); while (list($file_id, $chunk_id) = db_fetch_row($res)) { @@ -437,4 +684,7 @@ class AttachmentChunkedData { return $deleted; } } +AttachmentStorageBackend::register('T', 'AttachmentChunkedData'); +Signal::connect('cron', array('AttachmentChunkedData', 'deleteOrphans')); + ?> diff --git a/include/staff/settings-tickets.inc.php b/include/staff/settings-tickets.inc.php index ce7489d19ddd15863d7849d3c33930ee93029712..c12a3e29409d401d5036bc94f4f0e23bbf643cbc 100644 --- a/include/staff/settings-tickets.inc.php +++ b/include/staff/settings-tickets.inc.php @@ -240,6 +240,18 @@ 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 = AttachmentStorageBackend::allRegistered()) + && count($bks) > 1) { ?> + <tr> + <td width="180">Store Attachments:</td> + <td><select name="default_storage_bk"><?php + foreach ($bks as $char=>$class) { + ?><option 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.