Newer
Older
<?php
/*********************************************************************
class.file.php
Peter Rotich <peter@osticket.com>
http://www.osticket.com
Released under the GNU General Public License WITHOUT ANY WARRANTY.
See LICENSE.TXT for details.
vim: expandtab sw=4 ts=4 sts=4:
**********************************************************************/
require_once(INCLUDE_DIR.'class.signal.php');
require_once(INCLUDE_DIR.'class.error.php');
class AttachmentFile {
var $id;
var $ht;
function AttachmentFile($id) {
$this->id =0;
return ($this->load($id));
}
function load($id=0) {
if(!$id && !($id=$this->getId()))
return false;
$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 '
.' LEFT JOIN '.ATTACHMENT_TABLE.' a ON(a.file_id=f.id) '
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
.' LEFT JOIN '.TICKET_ATTACHMENT_TABLE.' t ON(t.file_id=f.id) '
.' WHERE f.id='.db_input($id)
.' GROUP BY f.id';
if(!($res=db_query($sql)) || !db_num_rows($res))
return false;
$this->ht=db_fetch_array($res);
$this->id =$this->ht['id'];
return true;
}
function reload() {
return $this->load();
}
function getHashtable() {
return $this->ht;
}
function getInfo() {
return $this->getHashtable();
}
function getNumTickets() {
return $this->ht['tickets'];
}
function isCanned() {
return ($this->ht['canned']);
}
function isInUse() {
return ($this->getNumTickets() || $this->isCanned());
}
function getId() {
return $this->id;
}
function getType() {
return $this->ht['type'];
}
function getMime() {
return $this->getType();
}
function getSize() {
return $this->ht['size'];
}
function getName() {
return $this->ht['name'];
}
function getKey() {
return $this->ht['key'];
}
function getSignature() {
$sig = $this->ht['signature'];
if (!$sig) return $this->getKey();
return $sig;
function lastModified() {
return $this->ht['created'];
}
/**
* Retrieve a signature that can be sent to scp/file.php?h= in order to
* download this file
*/
function getDownloadHash() {
return strtolower($this->getKey()
. md5($this->getId().session_id().strtolower($this->getKey())));
return FileStorageBackend::getInstance($this);
function sendData($redirect=true, $disposition='inline') {
if ($redirect && $bk->sendRedirectUrl($disposition))
@ini_set('zlib.output_compression', 'Off');
try {
$bk->passthru();
}
catch (IOException $ex) {
Http::response(404, 'File not found');
}
# XXX: This is horrible, and is subject to php's memory
# restrictions, etc. Don't use this function!
ob_start();
try {
$this->sendData(false);
}
catch (IOException $ex) {
Http::response(404, 'File not found');
}
$data = &ob_get_contents();
ob_end_clean();
return $data;
}
function delete() {
$sql='DELETE FROM '.FILE_TABLE.' WHERE id='.db_input($this->getId()).' LIMIT 1';
if(!db_query($sql) || !db_affected_rows())
return false;
if ($bk = $this->open())
$bk->unlink();
return true;
Http::cacheable($this->getSignature(), $this->lastModified(), $ttl);
function display($scale=false) {
if ($scale && extension_loaded('gd')) {
$image = imagecreatefromstring($this->getData());
$width = imagesx($image);
if ($scale <= $width) {
$height = imagesy($image);
if ($width > $height) {
$heightp = $height * (int)$scale / $width;
$widthp = $scale;
} else {
$widthp = $width * (int)$scale / $height;
$heightp = $scale;
}
$thumb = imagecreatetruecolor($widthp, $heightp);
$white = imagecolorallocate($thumb, 255,255,255);
imagefill($thumb, 0, 0, $white);
imagecopyresized($thumb, $image, 0, 0, 0, 0, $widthp,
$heightp, $width, $height);
header('Content-Type: image/png');
imagepng($thumb);
return;
}
}
header('Content-Type: '.($this->getType()?$this->getType():'application/octet-stream'));
$bk = $this->open();
if ($bk->sendRedirectUrl('inline'))
return;
Http::download($this->getName(), $this->getType() ?: 'application/octet-stream',
null, 'inline');
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('=','+','/'),
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('=','+','/'),
substr($sha1, 0, 16) . substr($md5, 0, 16));
return array($key, $hash);
}
/* Function assumes the files types have been validated */
Peter Rotich
committed
if(!$file['name'] || $file['error'] || !is_uploaded_file($file['tmp_name']))
list($key, $sig) = self::_getKeyAndHash($file['tmp_name'], true);
'size'=>$file['size'],
'name'=>$file['name'],
'signature'=>$sig,
'tmp_name'=>$file['tmp_name'],
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
function uploadLogo($file, &$error, $aspect_ratio=3) {
/* Borrowed in part from
* http://salman-w.blogspot.com/2009/04/crop-to-fit-image-using-aspphp.html
*/
if (!extension_loaded('gd'))
return self::upload($file, 'L');
$source_path = $file['tmp_name'];
list($source_width, $source_height, $source_type) = getimagesize($source_path);
switch ($source_type) {
case IMAGETYPE_GIF:
case IMAGETYPE_JPEG:
case IMAGETYPE_PNG:
break;
default:
// TODO: Return an error
$error = 'Invalid image file type';
return false;
}
$source_aspect_ratio = $source_width / $source_height;
if ($source_aspect_ratio >= $aspect_ratio)
return self::upload($file, 'L');
$error = 'Image is too square. Upload a wider image';
return false;
}
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']();
list($key, $file['signature'])
= self::_getKeyAndHash($file['data']);
if (!$file['key'])
$file['key'] = $key;
if (!isset($file['size']))
$file['size'] = strlen($file['data']);
}
// Check and see if the file is already on record
$sql = 'SELECT id, `key` 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 (list($id, $key) = db_fetch_row(db_query($sql))) {
$file['key'] = $key;
$sql='INSERT INTO '.FILE_TABLE.' SET created=NOW() '
.',type='.db_input(strtolower($file['type']))
.',`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)))
// 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']))
elseif (!$bk->write($file['data']) || !$bk->flush()) {
// XXX: Fallthrough to default backend if different?
return false;
}
# XXX: ft does not exists during the upgrade when attachments are
# migrated! Neither does `bk`
if ($ft) {
$sql = 'UPDATE '.FILE_TABLE.' SET bk='
.db_input($bk->getBkChar())
.' WHERE id='.db_input($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) {
// Copy the file to the new backend and hash the contents
$target = FileStorageBackend::lookup($bk, $this);
// Initialize hashing algorithm to verify uploaded contents
$algos = $target->getNativeHashAlgos();
$common_algo = 'sha1';
if ($algos && is_array($algos)) {
$supported = hash_algos();
foreach ($algos as $a) {
if (in_array(strtolower($a), $supported)) {
$common_algo = strtolower($a);
break;
}
}
}
$before = hash_init($common_algo);
// 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($target->getBlockSize())) {
hash_update($before, $block);
$target->write($block);
}
$target->flush();
// Ask the backend to generate its own hash if at all possible
if (!($target_hash = $target->getHashDigest($common_algo))) {
$after = hash_init($common_algo);
// Verify that the hash of the target file matches the hash of
// the source file
$target = FileStorageBackend::lookup($bk, $this);
while ($block = $target->read())
hash_update($after, $block);
$target_hash = hash_final($after);
}
if (hash_final($before) != $target_hash) {
$sql = 'UPDATE '.FILE_TABLE.' SET bk='
.db_input($target->getBkChar())
.' WHERE id='.db_input($this->getId());
if (!db_query($sql) || db_affected_rows()!=1)
Peter Rotich
committed
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 `key`='.db_input($hash);
if(($res=db_query($sql)) && db_num_rows($res))
list($id)=db_fetch_row($res);
return $id;
}
function lookup($id) {
$id = is_numeric($id)?$id:AttachmentFile::getIdByHash($id);
return ($id && ($file = new AttachmentFile($id)) && $file->getId()==$id)?$file:null;
}
static function create($info, &$errors) {
if (isset($info['encoding'])) {
switch ($info['encoding']) {
case 'base64':
$info['data'] = base64_decode($info['data']);
}
}
return self::save($info);
}
Method formats http based $_FILE uploads - plus basic validation.
@restrict - make sure file type & size are allowed.
*/
function format($files, $restrict=false) {
global $ost;
if(!$files || !is_array($files))
return null;
//Reformat $_FILE for the sane.
$attachments = array();
foreach($files as $k => $a) {
if(is_array($a))
foreach($a as $i => $v)
$attachments[$i][$k] = $v;
}
//Basic validation.
foreach($attachments as $i => &$file) {
//skip no file upload "error" - why PHP calls it an error is beyond me.
if($file['error'] && $file['error']==UPLOAD_ERR_NO_FILE) {
continue;
}
if($file['error']) //PHP defined error!
$file['error'] = 'File upload error #'.$file['error'];
elseif(!$file['tmp_name'] || !is_uploaded_file($file['tmp_name']))
$file['error'] = 'Invalid or bad upload POST';
elseif($restrict) { // make sure file type & size are allowed.
if(!$ost->isFileTypeAllowed($file))
$file['error'] = 'Invalid file type for '.Format::htmlchars($file['name']);
elseif($ost->getConfig()->getMaxFileSize()
&& $file['size']>$ost->getConfig()->getMaxFileSize())
$file['error'] = sprintf('File %s (%s) is too big. Maximum of %s allowed',
Format::htmlchars($file['name']),
Format::file_size($file['size']),
Format::file_size($ost->getConfig()->getMaxFileSize()));
}
}
unset($file);
return array_filter($attachments);
}
/**
* Removes files and associated meta-data for files which no ticket,
* canned-response, or faq point to any more.
*/
/* 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 = 'SELECT id FROM '.FILE_TABLE.' WHERE id NOT IN ('
.'SELECT file_id FROM '.TICKET_ATTACHMENT_TABLE
.' UNION '
.'SELECT file_id FROM '.ATTACHMENT_TABLE
if (!($res = db_query($sql)))
return false;
while (list($id) = db_fetch_row($res))
if (($file = self::lookup($id)) && !$file->delete())
break;
return true;
/* static */
function allLogos() {
$sql = 'SELECT id FROM '.FILE_TABLE.' WHERE ft="L"
ORDER BY created';
$logos = array();
$res = db_query($sql);
while (list($id) = db_fetch_row($res))
$logos[] = AttachmentFile::lookup($id);
return $logos;
}
var $meta;
static $desc = false;
static $registry;
static $blocksize = 131072;
/**
* 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.
*/
foreach (self::$registry as $tc=>$class)
if ($this instanceof $class)
return $tc;
}
static function isRegistered($type) {
return isset(self::$registry[$type]);
}
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);
}
/**
* Returns the optimal block size for the backend. When migrating, this
* size blocks would be best for sending to the ::write() method
*/
function getBlockSize() {
return static::$blocksize;
}
/**
* 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;
}
/**
* Called after all the blocks are sent to the ::write() method. This
* method should return boolean FALSE if flushing the data was
* somehow inhibited.
*/
function flush() {
return true;
}
/**
* 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) {
}
/**
* 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;
}
/**
* Fetches a list of hash algorithms that are supported transparently
* through the ::write() and ::upload() methods. After writing or
* uploading file content, the ::getHashDigest($algo) method can be
* called to get a hash of the remote content without fetching the
* entire data stream to verify the content locally.
*/
function getNativeHashAlgos() {
return array();
}
/**
* Returns a hash of the content calculated remotely by the storage
* backend. If this method fails, the hash chould be calculated by
* downloading the content and hashing locally
*/
function getHashDigest($algo) {
return false;
}
* 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 extends FileStorageBackend {
static $desc = "In the database";
static $blocksize = CHUNK_SIZE;
function __construct($file) {
$this->file = $file;
$this->_chunk = 0;
$this->_buffer = false;
function length() {
list($length) = db_fetch_row(db_query(
'SELECT SUM(LENGTH(filedata)) FROM '.FILE_CHUNK_TABLE
.' WHERE file_id='.db_input($this->file->getId())));
function read($amount=CHUNK_SIZE, $offset=0) {
# Read requested length of data from attachment chunks
while (strlen($this->_buffer) < $amount + $offset) {
list($buf) = @db_fetch_row(db_query(
'SELECT filedata FROM '.FILE_CHUNK_TABLE.' WHERE file_id='
.db_input($this->file->getId()).' AND chunk_id='.$this->_chunk++));
if (!$buf)
break;
$this->_buffer .= $buf;
}
$chunk = substr($this->_buffer, $offset, $amount);
$this->_buffer = substr($this->_buffer, $offset + $amount);
return $chunk;
function write($what, $chunk_size=CHUNK_SIZE) {
$block = substr($what, $offset, $chunk_size);
if (!$block) break;
if (!db_query('REPLACE INTO '.FILE_CHUNK_TABLE
.' SET filedata=0x'.bin2hex($block).', file_id='
.db_input($this->file->getId()).', chunk_id='.db_input($this->_chunk++)))
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');