diff --git a/include/class.auth.php b/include/class.auth.php index eb7b740923cccb67ecaf32a4c930bf37259465b9..e320a4f5ec384bfa16618c155d952973b4397c10 100644 --- a/include/class.auth.php +++ b/include/class.auth.php @@ -212,7 +212,7 @@ abstract class AuthenticationBackend { $bk->audit($result, $credentials); } - static function process($username, $password=null, &$errors) { + static function process($username, $password=null, &$errors=array()) { if (!$username) return false; diff --git a/include/class.import.php b/include/class.import.php new file mode 100644 index 0000000000000000000000000000000000000000..c128136b7439845714435a503f89ed131609e0bd --- /dev/null +++ b/include/class.import.php @@ -0,0 +1,193 @@ +<?php +/********************************************************************* + class.import.php + + Utilities for importing objects and data (usually via CSV) + + Peter Rotich <peter@osticket.com> + Jared Hancock <jared@osticket.com> + Copyright (c) 2006-2015 osTicket + 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: +**********************************************************************/ + +class ImportError extends Exception {} +class ImportDataError extends ImportError {} + +class CsvImporter { + var $stream; + + function __construct($stream) { + // File upload + if (is_array($stream) && !$stream['error']) { + // Properly detect Macintosh style line endings + ini_set('auto_detect_line_endings', true); + $this->stream = fopen($stream['tmp_name'], 'r'); + } + // Open file + elseif (is_resource($stream)) { + $this->stream = $stream; + } + // Text from standard-in + elseif (is_string($stream)) { + $this->stream = fopen('php://temp', 'w+'); + fwrite($this->stream, $stream); + rewind($this->stream); + } + else { + throw new ImportError(__('Unable to parse submitted csv: ').print_r($stream, true)); + } + } + + function __destruct() { + fclose($this->stream); + } + + function importCsv($all_fields=array(), $defaults=array()) { + $named_fields = array(); + $has_header = true; + foreach ($all_fields as $f) + if ($f->get('name')) + $named_fields[$f->get('name')] = $f; + + // Read the first row and see if it is a header or not + if (!($data = fgetcsv($this->stream, 1000, ","))) + throw new ImportError(__('Whoops. Perhaps you meant to send some CSV records')); + + $headers = array(); + foreach ($data as $h) { + $h = trim($h); + $found = false; + foreach ($all_fields as $f) { + if (in_array(mb_strtolower($h), array( + mb_strtolower($f->get('name')), mb_strtolower($f->get('label'))))) { + $found = true; + if (!$f->get('name')) + throw new ImportError(sprintf(__( + '%s: Field must have `variable` set to be imported'), $h)); + $headers[$f->get('name')] = $f->get('label'); + break; + } + } + if (!$found) { + $has_header = false; + if (count($data) == count($named_fields)) { + // Number of fields in the user form matches the number + // of fields in the data. Assume things line up + $headers = array(); + foreach ($named_fields as $f) + $headers[$f->get('name')] = $f->get('label'); + break; + } + else { + throw new ImportError(sprintf(__('%s: Unable to map header to a user field'), $h)); + } + } + } + + if (!$has_header) + fseek($this->stream, 0); + + $objects = $fields = array(); + foreach ($headers as $h => $label) { + if (!isset($named_fields[$h])) + continue; + + $f = $named_fields[$h]; + $name = $f->get('name'); + $fields[$name] = $f; + } + + // Add default fields (org_id, etc). + foreach ($defaults as $key => $val) { + // Don't apply defaults which are also being imported + if (isset($header[$key])) + unset($defaults[$key]); + } + + // Avoid reading the entire CSV before yielding the first record. + // Use an iterator. This will also allow row-level errors to be + // continuable such that the rows with errors can be handled and the + // iterator can continue with the next row. + return new CsvImportIterator($this->stream, $headers, $fields, $defaults); + } +} + +class CsvImportIterator +implements Iterator { + var $stream; + var $start = 0; + var $headers; + var $fields; + var $defaults; + + var $current = true; + var $row = 0; + + function __construct($stream, $headers, $fields, $defaults) { + $this->stream = $stream; + $this->start = ftell($stream); + $this->headers = $headers; + $this->fields = $fields; + $this->defaults = $defaults; + } + + // Iterator interface ------------------------------------- + function rewind() { + @fseek($this->stream, $this->start); + if (ftell($this->stream) != $this->start) + throw new RuntimeException('Stream cannot be rewound'); + $this->row = 0; + $this->next(); + } + function valid() { + return $this->current; + } + function current() { + return $this->current; + } + function key() { + return $this->row; + } + + function next() { + do { + if (($csv = fgetcsv($this->stream, 4096, ",")) === false) { + // Read error + $this->current = false; + break; + } + + if (count($csv) == 1 && $csv[0] == null) + // Skip empty rows + continue; + elseif (count($csv) != count($this->headers)) + throw new ImportDataError(sprintf(__('Bad data. Expected: %s'), + implode(', ', $this->headers))); + + // Validate according to field configuration + $i = 0; + $this->current = $this->defaults; + foreach ($this->headers as $h => $label) { + $f = $this->fields[$h]; + $f->_errors = array(); + $T = $f->parse($csv[$i]); + if ($f->validateEntry($T) && $f->errors()) + throw new ImportDataError(sprintf(__( + /* 1 will be a field label, and 2 will be error messages */ + '%1$s: Invalid data: %2$s'), + $label, implode(', ', $f->errors()))); + // Convert to database format + $this->current[$h] = $f->to_database($T); + $i++; + } + } + // Use the do-loop only for the empty line skipping + while (false); + $this->row++; + } +} diff --git a/include/class.staff.php b/include/class.staff.php index a40ea046ad140b8ee631e8085ce3e5c0333d3d3a..ed93f6441f7115cda215f6555a6544e39c989c48 100644 --- a/include/class.staff.php +++ b/include/class.staff.php @@ -831,6 +831,66 @@ implements AuthenticatedUser, EmailContact, TemplateVariable { $msg['body']); } + static function importCsv($stream, $defaults=array(), $callback=false) { + require_once INCLUDE_DIR . 'class.import.php'; + + $importer = new CsvImporter($stream); + $imported = 0; + $fields = array( + 'firstname' => new TextboxField(array( + 'label' => __('First name'), + )), + 'lastname' => new TextboxField(array( + 'label' => __('Last name'), + )), + 'email' => new TextboxField(array( + 'label' => __('Email Address'), + 'configuration' => array( + 'validator' => 'email', + ), + )), + 'username' => new TextboxField(array( + 'label' => __('Username'), + 'validators' => function($self, $value) { + if (!Validator::is_username($value)) + $self->addError('Not a valid username'); + }, + )), + ); + $form = new SimpleForm($fields); + + try { + db_autocommit(false); + $records = $importer->importCsv($form->getFields(), $defaults); + foreach ($records as $data) { + if (!isset($data['email']) || !isset($data['username'])) + throw new ImportError('Both `username` and `email` fields are required'); + + if ($agent = self::lookup(array('username' => $data['username']))) { + // TODO: Update the user + } + elseif ($agent = self::create($data, $errors)) { + if ($callback) + $callback($agent, $data); + $agent->save(); + } + else { + throw new ImportError(sprintf(__('Unable to import (%s): %s'), + $data['username'], + print_r($errors, true) + )); + } + $imported++; + } + db_autocommit(true); + } + catch (Exception $ex) { + db_rollback(); + return $ex->getMessage(); + } + return $imported; + } + function save($refetch=false) { if ($this->dirty) $this->updated = SqlFunction::NOW(); diff --git a/include/class.user.php b/include/class.user.php index dc599225c6ae05573c7e91f0cccbf7aeeeb6d123..16279f76eb834247940fc8f96f2e7807af497f64 100644 --- a/include/class.user.php +++ b/include/class.user.php @@ -456,138 +456,31 @@ implements TemplateVariable { } static function importCsv($stream, $defaults=array()) { - //Read the header (if any) - $headers = array('name' => __('Full Name'), 'email' => __('Email Address')); - $uform = UserForm::getUserForm(); - $all_fields = $uform->getFields(); - $named_fields = array(); - $has_header = true; - foreach ($all_fields as $f) - if ($f->get('name')) - $named_fields[] = $f; - - if (!($data = fgetcsv($stream, 1000, ","))) - return __('Whoops. Perhaps you meant to send some CSV records'); - - if (Validator::is_email($data[1])) { - $has_header = false; // We don't have an header! - } - else { - $headers = array(); - foreach ($data as $h) { - $found = false; - foreach ($all_fields as $f) { - if (in_array(mb_strtolower($h), array( - mb_strtolower($f->get('name')), mb_strtolower($f->get('label'))))) { - $found = true; - if (!$f->get('name')) - return sprintf(__( - '%s: Field must have `variable` set to be imported'), $h); - $headers[$f->get('name')] = $f->get('label'); - break; - } - } - if (!$found) { - $has_header = false; - if (count($data) == count($named_fields)) { - // Number of fields in the user form matches the number - // of fields in the data. Assume things line up - $headers = array(); - foreach ($named_fields as $f) - $headers[$f->get('name')] = $f->get('label'); - break; - } - else { - return sprintf(__('%s: Unable to map header to a user field'), $h); - } - } - } - } - - // 'name' and 'email' MUST be in the headers - if (!isset($headers['name']) || !isset($headers['email'])) - return __('CSV file must include `name` and `email` columns'); - - if (!$has_header) - fseek($stream, 0); - - $users = $fields = $keys = array(); - foreach ($headers as $h => $label) { - if (!($f = $uform->getField($h))) - continue; - - $name = $keys[] = $f->get('name'); - $fields[$name] = $f->getImpl(); - } - - // Add default fields (org_id, etc). - foreach ($defaults as $key => $val) { - // Don't apply defaults which are also being imported - if (isset($header[$key])) - unset($defaults[$key]); - $keys[] = $key; - } - - while (($data = fgetcsv($stream, 1000, ",")) !== false) { - if (count($data) == 1 && $data[0] == null) - // Skip empty rows - continue; - elseif (count($data) != count($headers)) - return sprintf(__('Bad data. Expected: %s'), implode(', ', $headers)); - // Validate according to field configuration - $i = 0; - foreach ($headers as $h => $label) { - $f = $fields[$h]; - $T = $f->parse($data[$i]); - if ($f->validateEntry($T) && $f->errors()) - return sprintf(__( - /* 1 will be a field label, and 2 will be error messages */ - '%1$s: Invalid data: %2$s'), - $label, implode(', ', $f->errors())); - // Convert to database format - $data[$i] = $f->to_database($T); - $i++; - } - // Add default fields - foreach ($defaults as $key => $val) - $data[] = $val; - - $users[] = $data; - } - - db_autocommit(false); - $error = false; - foreach ($users as $u) { - $vars = array_combine($keys, $u); - if (!static::fromVars($vars, true, true)) { - $error = sprintf(__('Unable to import user: %s'), - print_r($vars, true)); - break; + require_once INCLUDE_DIR . 'class.import.php'; + + $importer = new CsvImporter($stream); + $imported = 0; + try { + db_autocommit(false); + $records = $importer->importCsv(UserForm::getUserForm()->getFields(), $defaults); + foreach ($records as $data) { + if (!isset($data['email']) || !isset($data['name'])) + throw new ImportError('Both `name` and `email` fields are required'); + if (!($user = static::fromVars($data, true, true))) + throw new ImportError(sprintf(__('Unable to import user: %s'), + print_r($data, true))); + $imported++; } + db_autocommit(true); } - if ($error) + catch (Exception $ex) { db_rollback(); - - db_autocommit(true); - - return $error ?: count($users); - } - - function importFromPost($stuff, $extra=array()) { - if (is_array($stuff) && !$stuff['error']) { - // Properly detect Macintosh style line endings - ini_set('auto_detect_line_endings', true); - $stream = fopen($stuff['tmp_name'], 'r'); - } - elseif ($stuff) { - $stream = fopen('php://temp', 'w+'); - fwrite($stream, $stuff); - rewind($stream); - } - else { - return __('Unable to parse submitted users'); + return $ex->getMessage(); } + return $imported; + } + function importFromPost($stream, $extra=array()) { return User::importCsv($stream, $extra); } diff --git a/include/cli/modules/agent.php b/include/cli/modules/agent.php new file mode 100644 index 0000000000000000000000000000000000000000..7a55a351d6677a747e57e6f1b05d8f9e4d88978f --- /dev/null +++ b/include/cli/modules/agent.php @@ -0,0 +1,185 @@ +<?php + +class AgentManager extends Module { + var $prologue = 'CLI agent manager'; + + var $arguments = array( + 'action' => array( + 'help' => 'Action to be performed', + 'options' => array( + 'import' => 'Import agents from CSV file', + 'export' => 'Export agents from the system to CSV', + 'list' => 'List agents based on search criteria', + 'login' => 'Attempt login as an agent', + 'backends' => 'List agent authentication backends', + ), + ), + ); + + var $options = array( + 'file' => array('-f', '--file', 'metavar'=>'path', + 'help' => 'File or stream to process'), + 'verbose' => array('-v', '--verbose', 'default'=>false, + 'action'=>'store_true', 'help' => 'Be more verbose'), + + 'welcome' => array('-w', '--welcome', 'default'=>false, + 'action'=>'store_true', 'help'=>'Send a welcome email on import'), + + 'backend' => array('', '--backend', + 'help'=>'Specify the authentication backend (used with `login` and `import`)'), + + // -- Search criteria + 'username' => array('-U', '--username', + 'help' => 'Search by username'), + 'email' => array('-E', '--email', + 'help' => 'Search by email address'), + 'id' => array('-U', '--id', + 'help' => 'Search by user id'), + 'dept' => array('-D', '--dept', 'help' => 'Search by access to department name or id'), + 'team' => array('-T', '--team', 'help' => 'Search by membership in team name or id'), + ); + + var $stream; + + function run($args, $options) { + global $ost, $cfg; + + Bootstrap::connect(); + + if (!($ost=osTicket::start()) || !($cfg = $ost->getConfig())) + $this->fail('Unable to load config info!'); + + switch ($args['action']) { + case 'import': + // Properly detect Macintosh style line endings + ini_set('auto_detect_line_endings', true); + + if (!$options['file'] || $options['file'] == '-') + $options['file'] = 'php://stdin'; + if (!($this->stream = fopen($options['file'], 'rb'))) + $this->fail("Unable to open input file [{$options['file']}]"); + + // Defaults + $extras = array( + 'isadmin' => 0, + 'isactive' => 1, + 'isvisible' => 1, + 'dept_id' => $cfg->getDefaultDeptId(), + 'timezone' => $cfg->getDefaultTimezone(), + 'welcome_email' => $options['welcome'], + ); + + if ($options['backend']) + $extras['backend'] = $options['backend']; + + $stderr = $this->stderr; + $status = Staff::importCsv($this->stream, $extras, + function ($agent, $data) use ($stderr, $options) { + if (!$options['verbose']) + return; + $stderr->write( + sprintf("\n%s - %s --- imported!", + $agent->getName(), + $agent->getUsername())); + } + ); + if (is_numeric($status)) + $this->stderr->write("Successfully processed $status agents\n"); + else + $this->fail($status); + break; + + case 'export': + $stream = $options['file'] ?: 'php://stdout'; + if (!($this->stream = fopen($stream, 'c'))) + $this->fail("Unable to open output file [{$options['file']}]"); + + fputcsv($this->stream, array('First Name', 'Last Name', 'Email', 'UserName')); + foreach ($this->getAgents($options) as $agent) + fputcsv($this->stream, array( + $agent->getFirstName(), + $agent->getLastName(), + $agent->getEmail(), + $agent->getUserName(), + )); + break; + + case 'list': + $agents = $this->getAgents($options); + foreach ($agents as $A) { + $this->stdout->write(sprintf( + "%d \t - %s\t<%s>\n", + $A->staff_id, $A->getName(), $A->getEmail())); + } + break; + + case 'login': + $this->stderr->write('Username: '); + $username = trim(fgets(STDIN)); + $this->stderr->write('Password: '); + $password = trim(fgets(STDIN)); + + $agent = null; + foreach (StaffAuthenticationBackend::allRegistered() as $id=>$bk) { + if ((!$options['backend'] || $options['backend'] == $id) + && $bk->supportsInteractiveAuthentication() + && ($agent = $bk->authenticate($username, $password)) + && $agent instanceof AuthenticatedUser + ) { + break; + } + } + + if ($agent instanceof Staff) { + $this->stdout->write(sprintf("Successfully authenticated as '%s', using '%s'\n", + (string) $agent->getName(), + $bk->getName() + )); + } + else { + $this->stdout->write('Authentication failed'); + } + break; + + case 'backends': + foreach (StaffAuthenticationBackend::allRegistered() as $name=>$bk) { + if (!$bk->supportsInteractiveAuthentication()) + continue; + $this->stdout->write(sprintf("%s\t%s\n", + $name, $bk->getName() + )); + } + break; + + default: + $this->fail($args['action'].': Unknown action!'); + } + @fclose($this->stream); + } + + function getAgents($options, $requireOne=false) { + $agents = Staff::objects(); + + if ($options['email']) + $agents->filter(array('email__contains' => $options['email'])); + if ($options['username']) + $agents->filter(array('username__contains' => $options['username'])); + if ($options['id']) + $agents->filter(array('staff_id' => $options['id'])); + if ($options['dept']) + $agents->filter(Q::any(array( + 'dept_id' => $options['dept'], + 'dept__name__contains' => $options['dept'], + 'dept_access__dept_id' => $options['dept'], + 'dept_access__dept__name__contains' => $options['dept'], + ))); + if ($options['team']) + $agents->filter(Q::any(array( + 'teams__team_id' => $options['team'], + 'teams__team__name__contains' => $options['team'], + ))); + + return $agents->distinct('staff_id'); + } +} +Module::register('agent', 'AgentManager'); diff --git a/include/cli/modules/user.php b/include/cli/modules/user.php index 1504aacd2fcfaba3b423f82147e4a505e3c699eb..00dd6af894463a53fe61e5567c5d84ae3f7c118d 100644 --- a/include/cli/modules/user.php +++ b/include/cli/modules/user.php @@ -56,9 +56,9 @@ class UserManager extends Module { // Properly detect Macintosh style line endings ini_set('auto_detect_line_endings', true); - if (!$options['file']) - $this->fail('CSV file to import users from is required!'); - elseif (!($this->stream = fopen($options['file'], 'rb'))) + if (!$options['file'] || $options['file'] == '-') + $options['file'] = 'php://stdin'; + if (!($this->stream = fopen($options['file'], 'rb'))) $this->fail("Unable to open input file [{$options['file']}]"); $extras = array();