You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
514 lines
20 KiB
514 lines
20 KiB
<?php
|
|
|
|
class FileManager extends Module {
|
|
var $prologue = 'CLI file manager for osTicket';
|
|
|
|
var $arguments = array(
|
|
'action' => array(
|
|
'help' => 'Action to be performed',
|
|
'options' => array(
|
|
'list' => 'List files matching criteria',
|
|
'export' => 'Export files from the system',
|
|
'import' => 'Load files exported via `export`',
|
|
'zip' => 'Create a zip file of the matching files',
|
|
'dump' => 'Dump file content to stdout',
|
|
'load' => 'Load file contents from stdin',
|
|
'migrate' => 'Migrate a file to another backend',
|
|
'backends' => 'List configured storage backends',
|
|
'expunge' => 'Remove matching files from the system',
|
|
),
|
|
),
|
|
);
|
|
|
|
var $options = array(
|
|
'ticket' => array('-T', '--ticket', 'metavar'=>'id',
|
|
'help' => 'Search by internal ticket id'),
|
|
'file-id' => array('-F', '--file-id', 'metavar'=>'id',
|
|
'help' => 'Search by file id'),
|
|
'name' => array('-N', '--name', 'metavar'=>'name',
|
|
'help' => 'Search by file name (subsring match)'),
|
|
'backend' => array('-b', '--backend', 'metavar'=>'BK',
|
|
'help' => 'Search by file backend. See `backends` action
|
|
for a list of available backends'),
|
|
'status' => array('-S', '--status', 'metavar'=>'STATUS',
|
|
'help' => 'Search on ticket state (`open` or `closed`)'),
|
|
'min-size' => array('-z', '--min-size', 'metavar'=>'SIZE',
|
|
'help' => 'Search for files larger than this. k, M, G are welcome'),
|
|
'max-size' => array('-Z', '--max-size', 'metavar'=>'SIZE',
|
|
'help' => 'Search for files smaller than this. k, M, G are welcome'),
|
|
|
|
'limit' => array('-L', '--limit', 'metavar'=>'count',
|
|
'help' => 'Limit search results to this count'),
|
|
|
|
'to' => array('-m', '--to', 'metavar'=>'BK',
|
|
'help' => 'Target backend for migration. See `backends` action
|
|
for a list of available backends'),
|
|
|
|
'file' => array('-f', '--file', 'metavar'=>'FILE',
|
|
'help' => 'Filename used for import and export'),
|
|
|
|
'verbose' => array('-v', '--verbose', 'action'=>'store_true',
|
|
'help' => 'Be more verbose'),
|
|
);
|
|
|
|
|
|
function run($args, $options) {
|
|
Bootstrap::connect();
|
|
osTicket::start();
|
|
|
|
switch ($args['action']) {
|
|
case 'backends':
|
|
// List configured backends
|
|
foreach (FileStorageBackend::allRegistered(true) as $char=>$bk) {
|
|
print "$char -- {$bk::$desc} ($bk)\n";
|
|
}
|
|
break;
|
|
|
|
case 'list':
|
|
// List files matching criteria
|
|
// ORM would be nice!
|
|
$files = AttachmentFile::objects();
|
|
$this->_applyCriteria($options, $files);
|
|
foreach ($files as $f) {
|
|
printf("% 5d %s % 8d %s % 16s %s\n", $f->id, $f->bk,
|
|
$f->size, $f->created, $f->type, $f->name);
|
|
if ($f->attrs) {
|
|
printf(" %s\n", $f->attrs);
|
|
}
|
|
}
|
|
break;
|
|
|
|
case 'dump':
|
|
$files = AttachmentFile::objects();
|
|
$this->_applyCriteria($options, $files);
|
|
try {
|
|
$f = $files->one();
|
|
}
|
|
catch (DoesNotExist $e) {
|
|
$this->fail('No file matches the given criteria');
|
|
}
|
|
catch (ObjectNotUnique $e) {
|
|
$this->fail('Criteria must select exactly 1 file');
|
|
}
|
|
|
|
if ($bk = $f->open())
|
|
$bk->passthru();
|
|
break;
|
|
|
|
case 'load':
|
|
// Load file content from STDIN
|
|
$files = AttachmentFile::objects();
|
|
$this->_applyCriteria($options, $files);
|
|
try {
|
|
$f = $files->one();
|
|
}
|
|
catch (DoesNotExist $e) {
|
|
$this->fail('No file matches the given criteria');
|
|
}
|
|
catch (ObjectNotUnique $e) {
|
|
$this->fail('Criteria must select exactly 1 file');
|
|
}
|
|
|
|
try {
|
|
if ($bk = $f->open())
|
|
$bk->unlink();
|
|
}
|
|
catch (Exception $e) {}
|
|
|
|
if ($options['to'])
|
|
$bk = FileStorageBackend::lookup($options['to'], $f);
|
|
else
|
|
// Use the system default
|
|
$bk = AttachmentFile::getBackendForFile($f);
|
|
|
|
$type = false;
|
|
$signature = '';
|
|
$finfo = new finfo(FILEINFO_MIME_TYPE);
|
|
if ($options['file'] && $options['file'] != '-') {
|
|
if (!file_exists($options['file']))
|
|
$this->fail($options['file'].': Cannot open file');
|
|
if (!$bk->upload($options['file']))
|
|
$this->fail('Unable to upload file contents to backend');
|
|
$type = $finfo->file($options['file']);
|
|
list(, $signature) = AttachmentFile::_getKeyAndHash($options['file'], true);
|
|
}
|
|
else {
|
|
$stream = fopen('php://stdin', 'rb');
|
|
// reading from the stream will likely return an amount of
|
|
// data different from the backend requested block size. Loop
|
|
// until $read_size bytes are recieved.
|
|
while (true) {
|
|
$contents = '';
|
|
$read_size = $bk->getBlockSize();
|
|
while ($read_size > 0 && ($block = fread($stream, $read_size))) {
|
|
$contents .= $block;
|
|
$read_size -= strlen($block);
|
|
}
|
|
if (!$contents)
|
|
break;
|
|
if (!$bk->write($contents))
|
|
$this->fail('Unable to send file contents to backend');
|
|
if (!$type)
|
|
$type = $finfo->buffer($contents);
|
|
}
|
|
if (!$bk->flush())
|
|
$this->fail('Unable to commit file contents to backend');
|
|
}
|
|
|
|
// TODO: Update file metadata
|
|
$f->bk = $bk->getBkChar();
|
|
$f->created = SqlFunction::NOW();
|
|
$f->type = $type;
|
|
$f->signature = $signature;
|
|
|
|
if (!$f->save())
|
|
$this->fail('Unable to update file metadata');
|
|
|
|
$this->stdout->write("Successfully saved contents\n");
|
|
break;
|
|
|
|
case 'migrate':
|
|
if (!$options['to'])
|
|
$this->fail('Please specify a target backend for migration');
|
|
|
|
if (!FileStorageBackend::isRegistered($options['to']))
|
|
$this->fail('Target backend is not installed. See `backends` action');
|
|
|
|
$files = AttachmentFile::objects();
|
|
$this->_applyCriteria($options, $files);
|
|
|
|
$count = 0;
|
|
foreach ($files as $f) {
|
|
if ($f->getBackend() == $options['to'])
|
|
continue;
|
|
if ($options['verbose'])
|
|
$this->stdout->write('Migrating '.$f->name."\n");
|
|
try {
|
|
if (!$f->migrate($options['to']))
|
|
$this->stderr->write('Unable to migrate '.$f->name."\n");
|
|
else
|
|
$count++;
|
|
}
|
|
catch (IOException $e) {
|
|
$this->stderr->write('IOError: '.$e->getMessage());
|
|
}
|
|
}
|
|
$this->stdout->write("Migrated $count files\n");
|
|
break;
|
|
|
|
/**
|
|
* export
|
|
*
|
|
* Export file contents to a stream file. The format of the stream
|
|
* will be a continuous stream of file information in the following
|
|
* format:
|
|
*
|
|
* AFIL<meta-length><data-length><meta><data>EOF\x1c
|
|
*
|
|
* Where
|
|
* A is the version code of the export
|
|
* "FIL" is the literal text 'FIL'
|
|
* meta-length is 'V' packed header length (bytes)
|
|
* data-length is 'V' packed data length (bytes)
|
|
* meta is the %file record, php serialized
|
|
* data is the raw content of the file
|
|
* "EOF" is the literal text 'EOF'
|
|
* \x1c is an ASCII 0x1c byte (file separator)
|
|
*
|
|
* Options:
|
|
* --file File to which to direct the stream output, default
|
|
* is stdout
|
|
*/
|
|
case 'export':
|
|
$files = AttachmentFile::objects();
|
|
$this->_applyCriteria($options, $files);
|
|
|
|
if (!$options['file'] || $options['file'] == '-')
|
|
$options['file'] = 'php://stdout';
|
|
|
|
if (!($stream = fopen($options['file'], 'wb')))
|
|
$this->fail($options['file'].': Unable to open file for export stream');
|
|
|
|
foreach ($files as $f) {
|
|
if ($options['verbose'])
|
|
$this->stderr->write($f->name."\n");
|
|
|
|
// TODO: Log %attachment and %ticket_attachment entries
|
|
$info = array('file' => $f->getInfo());
|
|
$header = serialize($info);
|
|
fwrite($stream, 'AFIL'.pack('VV', strlen($header), $f->getSize()));
|
|
fwrite($stream, $header);
|
|
$FS = $f->open();
|
|
while ($block = $FS->read())
|
|
fwrite($stream, $block);
|
|
fwrite($stream, "EOF\x1c");
|
|
}
|
|
fclose($stream);
|
|
break;
|
|
|
|
/**
|
|
* import
|
|
*
|
|
* Import a collection of file contents exported by the `export`.
|
|
* See the export function above for details about the stream
|
|
* format.
|
|
*
|
|
* Options:
|
|
* --file File from which to read the export stream, default
|
|
* is stdin
|
|
* --to Backend to receive the contents (@see `backends`)
|
|
* --verbose Show file names while importing
|
|
*/
|
|
case 'import':
|
|
if (!$options['file'] || $options['file'] == '-')
|
|
$options['file'] = 'php://stdin';
|
|
|
|
if (!($stream = fopen($options['file'], 'rb')))
|
|
$this->fail($options['file'].': Unable to open import stream');
|
|
|
|
while (true) {
|
|
// Read the file header
|
|
// struct file_data_header {
|
|
// char[4] marker; // Four chars, 'AFIL'
|
|
// int lenMeta;
|
|
// int lenData;
|
|
// };
|
|
if (!($header = fread($stream, 12)))
|
|
break; // EOF
|
|
|
|
list(, $mark, $hlen, $dlen) = unpack('V3', $header);
|
|
|
|
// AFIL written as little-endian 4-byte int is 0x4c4946xx (LIFA),
|
|
// where 'A' is the version code of the export
|
|
$version = $mark & 0xff;
|
|
if (($mark >> 8) != 0x4c4946)
|
|
$this->fail('Bad file record');
|
|
|
|
// Read the header
|
|
$header = fread($stream, $hlen);
|
|
if (strlen($header) != $hlen)
|
|
$this->fail('Short read getting header info');
|
|
|
|
$header = unserialize($header);
|
|
if (!$header)
|
|
$this->fail('Unable to decipher file header');
|
|
|
|
// Find or create the file record
|
|
$finfo = $header['file'];
|
|
// TODO: Consider the $version code, drop columns which do
|
|
// not exist in this database schema
|
|
$f = AttachmentFile::lookup($finfo['id']);
|
|
if ($f) {
|
|
// Verify file information
|
|
if ($f->getSize() != $finfo['size']
|
|
|| $f->getSignature() != $finfo['signature']
|
|
) {
|
|
$this->fail(sprintf(
|
|
'%s: File data does not match existing file record',
|
|
$finfo['name']
|
|
));
|
|
}
|
|
// Drop existing file contents, if any
|
|
try {
|
|
if ($bk = $f->open())
|
|
$bk->unlink();
|
|
}
|
|
catch (Exception $e) {}
|
|
}
|
|
// Create a new file
|
|
else {
|
|
// Bypass the AttachmentFile::create() because we do not
|
|
// have the data to send yet.
|
|
$f = new AttachmentFile($finfo);
|
|
if (!$f->save(true)) {
|
|
$this->fail(sprintf(
|
|
'%s: Unable to create new file record',
|
|
$finfo['name']));
|
|
}
|
|
}
|
|
|
|
// Determine the backend to recieve the file contents
|
|
if ($options['to']) {
|
|
$bk = FileStorageBackend::lookup($options['to'], $f);
|
|
}
|
|
// Use the system default
|
|
else {
|
|
$bk = AttachmentFile::getBackendForFile($f);
|
|
}
|
|
|
|
if ($options['verbose'])
|
|
$this->stdout->write('Importing '.$f->getName()."\n");
|
|
|
|
// Write file contents to the backend
|
|
$md5 = hash_init('md5');
|
|
$sha1 = hash_init('sha1');
|
|
$written = 0;
|
|
|
|
// Handle exceptions by dropping imported file contents and
|
|
// then returning the error to the error output stream.
|
|
try {
|
|
while ($dlen > 0) {
|
|
$read_size = min($dlen, $bk->getBlockSize());
|
|
$contents = '';
|
|
// reading from the stream will likely return an amount of
|
|
// data different from the backend requested block size. Loop
|
|
// until $read_size bytes are recieved.
|
|
while ($read_size > 0 && ($block = fread($stream, $read_size))) {
|
|
$contents .= $block;
|
|
$read_size -= strlen($block);
|
|
}
|
|
if ($read_size != 0) {
|
|
// short read
|
|
throw new Exception(sprintf(
|
|
'%s: Some contents are missing from the stream',
|
|
$f->getName()
|
|
));
|
|
}
|
|
// Calculate MD5 and SHA1 hashes of the file to verify
|
|
// contents after successfully written to backend
|
|
if (!$bk->write($contents))
|
|
throw new Exception(
|
|
'Unable to send file contents to backend');
|
|
hash_update($md5, $contents);
|
|
hash_update($sha1, $contents);
|
|
$dlen -= strlen($contents);
|
|
$written += strlen($contents);
|
|
}
|
|
// Some backends cannot handle flush() without a
|
|
// corresponding write() call.
|
|
if ($written && !$bk->flush())
|
|
throw new Exception(
|
|
'Unable to commit file contents to backend');
|
|
|
|
// Check the signature hash
|
|
if ($finfo['signature']) {
|
|
$md5 = base64_encode(hash_final($md5, true));
|
|
$sha1 = base64_encode(hash_final($sha1, true));
|
|
$sig = str_replace(
|
|
array('=','+','/'),
|
|
array('','-','_'),
|
|
substr($sha1, 0, 16) . substr($md5, 0, 16));
|
|
if ($sig != $finfo['signature']) {
|
|
throw new Exception(sprintf(
|
|
'%s: Signature verification failed',
|
|
$f->getName()
|
|
));
|
|
}
|
|
}
|
|
|
|
// Update file to record current backend
|
|
$f->bk = $bk->getBkChar();
|
|
if (!$f->save())
|
|
return false;
|
|
|
|
} // end try
|
|
catch (Exception $ex) {
|
|
if ($bk) $bk->unlink();
|
|
$this->fail($ex->getMessage());
|
|
}
|
|
|
|
// Read file record footer
|
|
$footer = fread($stream, 4);
|
|
if (strlen($footer) != 4)
|
|
$this->fail('Unable to read file EOF marker');
|
|
list(, $footer) = unpack('N', $footer);
|
|
// Footer should be EOF\x1c as an int
|
|
if ($footer != 0x454f461c)
|
|
$this->fail('Incorrect file EOF marker');
|
|
}
|
|
break;
|
|
|
|
case 'zip':
|
|
// Create a temporary ZIP file
|
|
$files = AttachmentFile::objects();
|
|
$this->_applyCriteria($options, $files);
|
|
if (!$options['file'])
|
|
$this->fail('Please specify zip file with `-f`');
|
|
|
|
$zip = new ZipArchive();
|
|
if (true !== ($reason = $zip->open($options['file'],
|
|
ZipArchive::CREATE)))
|
|
$this->fail($reason.': Unable to create zip file');
|
|
|
|
foreach ($files as $f) {
|
|
if ($options['verbose'])
|
|
$this->stderr->write($f->name."\n");
|
|
$info = pathinfo($f->getName());
|
|
$name = Charset::transcode(
|
|
sprintf('%s-%d.%s',
|
|
$info['filename'], $f->getId(), $info['extension']),
|
|
'utf-8', 'cp437');
|
|
$zip->addFromString($name, $f->getData());
|
|
}
|
|
$zip->close();
|
|
break;
|
|
|
|
case 'expunge':
|
|
$files = AttachmentFile::objects();
|
|
$this->_applyCriteria($options, $files);
|
|
|
|
foreach ($files as $f) {
|
|
// Drop associated attachment links
|
|
$f->attachments->expunge();
|
|
|
|
// Drop file contents
|
|
if ($bk = $f->open())
|
|
$bk->unlink();
|
|
|
|
// Drop file record
|
|
$f->delete();
|
|
}
|
|
}
|
|
}
|
|
|
|
function _applyCriteria($options, $qs) {
|
|
foreach ($options as $name=>$val) {
|
|
if (!$val) continue;
|
|
switch ($name) {
|
|
case 'ticket':
|
|
$qs->filter(array('attachments__thread_entry__thread__ticket__ticket_id'=>$val));
|
|
$qs->distinct('id');
|
|
break;
|
|
case 'file-id':
|
|
$qs->filter(array('id'=>$val));
|
|
break;
|
|
case 'name':
|
|
$qs->filter(array('name__contains'=>$val));
|
|
break;
|
|
case 'backend':
|
|
$qs->filter(array('bk'=>$val));
|
|
break;
|
|
case 'status':
|
|
if (!in_array($val, array('open','closed','archived','deleted')))
|
|
$this->fail($val.': Unknown ticket status');
|
|
|
|
$qs->filter(array('attachments__thread_entry__thread__ticket__status__state'=>$val));
|
|
$qs->distinct('id');
|
|
break;
|
|
|
|
case 'min-size':
|
|
case 'max-size':
|
|
$info = array();
|
|
if (!preg_match('/([\d.]+)([kmgbi]+)?/i', $val, $info))
|
|
$this->fail($val.': Invalid file size');
|
|
if ($info[2]) {
|
|
$info[2] = str_replace(array('b','i'), array('',''), $info[2]);
|
|
$sizes = array('k'=>1<<10,'m'=>1<<20,'g'=>1<<30);
|
|
$val = (float) $val * $sizes[strtolower($info[2])];
|
|
}
|
|
if ($name == 'min-size')
|
|
$qs->filter(array('size__gte'=>$val));
|
|
else
|
|
$qs->filter(array('size__lte'=>$val));
|
|
break;
|
|
|
|
case 'limit':
|
|
if (!is_numeric($val))
|
|
$this->fail('Provide an result count number to --limit');
|
|
$qs->limit($val);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Module::register('file', 'FileManager');
|