Skip to content
Snippets Groups Projects
file.php 19.9 KiB
Newer Older
  • Learn to ignore specific revisions
  • <?php
    require_once dirname(__file__) . "/class.module.php";
    require_once dirname(__file__) . "/../cli.inc.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() as $char=>$bk) {
                    print "$char -- {$bk::$desc} ($bk)\n";
                }
                break;
    
            case 'list':
                // List files matching criteria
                // ORM would be nice!
                $files = FileModel::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 = FileModel::objects();
                $this->_applyCriteria($options, $files);
                if ($files->count() != 1)
                    $this->fail('Criteria must select exactly 1 file');
    
    
                if (($f = AttachmentFile::lookup($files[0]->id))
                        && ($bk = $f->open()))
                    $bk->passthru();
    
            case 'load':
                // Load file content from STDIN
                $files = FileModel::objects();
                $this->_applyCriteria($options, $files);
                if ($files->count() != 1)
                    $this->fail('Criteria must select exactly 1 file');
    
                $f = AttachmentFile::lookup($files[0]->id);
                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');
                    while ($block = fread($stream, $bk->getBlockSize())) {
                        if (!$bk->write($block))
                            $this->fail('Unable to send file contents to backend');
                        if (!$type)
                            $type = $finfo->buffer($block);
                    }
                    if (!$bk->flush())
                        $this->fail('Unable to commit file contents to backend');
                }
    
                // TODO: Update file metadata
                $sql = 'UPDATE '.FILE_TABLE.' SET bk='.db_input($bk->getBkChar())
                    .', created=CURRENT_TIMESTAMP'
                    .', type='.db_input($type)
    
                    .', signature='.db_input($signature)
    
                    .' WHERE id='.db_input($f->getId());
    
                if (!db_query($sql) || db_affected_rows()!=1)
                    $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 = FileModel::objects();
                $this->_applyCriteria($options, $files);
    
                $count = 0;
                foreach ($files as $m) {
                    $f = AttachmentFile::lookup($m->id);
                    if ($f->getBackend() == $options['to'])
                        continue;
                    if ($options['verbose'])
                        $this->stdout->write('Migrating '.$m->name."\n");
                    try {
                        if (!$f->migrate($options['to']))
                            $this->stderr->write('Unable to migrate '.$m->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:
             *
    
             * FILE<meta-length><data-length><meta><data>EOF\x1c
    
             *
             * Where
             *   "FILE"         is the literal text 'FILE'
    
             *   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 = FileModel::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 $m) {
                    $f = AttachmentFile::lookup($m->id);
    
                    if ($options['verbose'])
                        $this->stderr->write($m->name."\n");
    
    
                    // TODO: Log %attachment and %ticket_attachment entries
                    $info = array('file' => $f->getInfo());
                    foreach ($m->tickets as $t)
                        $info['tickets'][] = $t->ht;
    
                    $header = serialize($info);
                    fwrite($stream, 'FILE'.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, 'BYTE'
                    //   int     lenMeta,
                    //   int     lenData,
                    // };
                    if (!($header = fread($stream, 12)))
                        break; // EOF
    
    
                    list(, $mark, $hlen, $dlen) = unpack('V3', $header);
    
                    // FILE written as little-endian 4-byte int is 0x454c4946 (ELIF)
                    if ($mark != 0x454c4946)
                        $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'];
                    $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 {
                        $fm = FileModel::create($finfo);
                        if (!$fm->save() || !($f = AttachmentFile::lookup($fm->id))) {
                            $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');
    
                    // 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()
                                ));
                            }
    
                    } // 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');
    
            case 'zip':
                // Create a temporary ZIP file
                $files = FileModel::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 $m) {
                    $f = AttachmentFile::lookup($m->id);
                    if ($options['verbose'])
                        $this->stderr->write($m->name."\n");
                    $name = Format::encode(sprintf(
                        '%d-%s', $f->getId(), $f->getName()
                        ), 'utf-8', 'cp437');
                    $zip->addFromString($name, $f->getData());
                }
                $zip->close();
                break;
    
    
            case 'expunge':
                $files = FileModel::objects();
                $this->_applyCriteria($options, $files);
    
    
                foreach ($files as $m) {
                    // Drop associated attachment links
                    $m->tickets->expunge();
                    $f = AttachmentFile::lookup($m->id);
    
                    // 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('tickets__ticket_id'=>$val));
                    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')))
                        $this->fail($val.': Unknown ticket status');
    
                    $qs->filter(array('tickets__ticket__status'=>$val));
                    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;
    
                }
            }
        }
    }
    
    require_once INCLUDE_DIR . 'class.orm.php';
    
    class FileModel extends VerySimpleModel {
        static $meta = array(
            'table' => FILE_TABLE,
            'pk' => 'id',
            'joins' => array(
                'tickets' => array(
                    'null' => true,
                    'constraint' => array('id' => 'TicketAttachmentModel.file_id')
                ),
            ),
        );
    }
    class TicketAttachmentModel extends VerySimpleModel {
        static $meta = array(
            'table' => TICKET_ATTACHMENT_TABLE,
            'pk' => 'attach_id',
            'joins' => array(
                'ticket' => array(
                    'null' => false,
                    'constraint' => array('ticket_id' => 'TicketModel.ticket_id'),
                ),
            ),
        );
    }
    
    
    class AttachmentModel extends VerySimpleModel {
        static $meta = array(
            'table' => ATTACHMENT_TABLE,
            'pk' => array('object_id', 'type', 'file_id'),
        );
    }
    
    
    Module::register('file', 'FileManager');
    ?>