1<?php 2 3use dokuwiki\Form\Form; 4 5/** 6 * Admin plugin based on usermanager with additional features: 7 * - importing passwords 8 * - setting defaults for empty values 9 */ 10class admin_plugin_userimportextended extends DokuWiki_Admin_Plugin 11{ 12 const DEFAULT_EMPTY = ['groups']; 13 14 /** @var auth_plugin_authplain */ 15 protected $_auth; 16 /** @var array */ 17 protected $_import_failures = []; 18 /** @var array */ 19 protected $defaults = ['email', 'name', 'password', 'groups']; 20 21 /** 22 * Constructor 23 */ 24 public function __construct() 25 { 26 /** @var auth_plugin_authplain $auth */ 27 global $auth; 28 29 if (!$auth instanceof auth_plugin_authplain) { 30 msg($this->getLang('error_badauth')); 31 return; 32 } 33 34 $this->_auth = $auth; 35 36 // attempt to retrieve any import failures from the session 37 if (!empty($_SESSION['import_failures'])){ 38 $this->_import_failures = $_SESSION['import_failures']; 39 } 40 } 41 42 /** 43 * handle user request 44 */ 45 public function handle() 46 { 47 global $INPUT; 48 $cmd = $INPUT->param('cmd'); 49 if (!empty($cmd)) { 50 switch(key($cmd)) { 51 case "import": 52 if (!checkSecurityToken()) return false; 53 if (!$this->_auth->canDo('addUser')) return false; 54 55 if ($this->validateDefaults() === true) { 56 $this->_import(); 57 } 58 break; 59 case "importfails": 60 $this->_downloadImportFailures(); 61 break; 62 } 63 } 64 return true; 65 } 66 67 /** 68 * Output html of the admin page 69 */ 70 public function html() 71 { 72 print $this->locale_xhtml('intro'); 73 $this->printFormHTML(); 74 $this->printFailuresHTML(); 75 } 76 77 /** 78 * Prints the import form 79 */ 80 protected function printFormHTML() 81 { 82 $form = new Form(['enctype' => 'multipart/form-data', 'id' => 'plugin__userimportextended_csv']); 83 $form->setHiddenField('do', 'admin'); 84 $form->setHiddenField('page', $this->getPluginName()); 85 $form->addFieldsetOpen($this->getLang('legend_defaults')); 86 $form->addTextInput('defaults[name]', $this->getLang('form_name') . '*'); 87 $form->addHTML('<br>'); 88 $form->addTextInput('defaults[email]', $this->getLang('form_email') . '*'); 89 $form->addHTML('<br>'); 90 $form->addTextInput('defaults[password]', $this->getLang('form_password') . '*'); 91 $form->addHTML('<br>'); 92 $form->addTextInput('defaults[groups]', $this->getLang('form_groups')); 93 $form->addFieldsetClose(); 94 $form->addFieldsetOpen($this->getLang('legend_csv')); 95 $form->addElement(new \dokuwiki\Form\InputElement('file', 'import'))->attr('accept', '.csv'); 96 $form->addHTML('<br>'); 97 $form->addButton('cmd[import]', $this->getLang('btn_import')); 98 $form->addFieldsetClose(); 99 echo $form->toHTML(); 100 } 101 102 /** 103 * Prints a table of failed imports 104 */ 105 protected function printFailuresHTML() 106 { 107 var_dump($this->lang); 108 109 global $ID; 110 $failure_download_link = wl($ID,array('do'=>'admin','page'=>'userimportextended','cmd[importfails]'=>1)); 111 112 if ($this->_import_failures) { 113 $digits = strlen(count($this->_import_failures)); 114 ptln('<div class="level3 import_failures">'); 115 ptln(' <h3>'.$this->getLang('import_header').'</h3>'); 116 ptln(' <table class="import_failures">'); 117 ptln(' <thead>'); 118 ptln(' <tr>'); 119 ptln(' <th class="line">'.$this->getLang('line').'</th>'); 120 ptln(' <th class="error">'.$this->getLang('error').'</th>'); 121 ptln(' <th class="userid">'.$this->getLang('user_id').'</th>'); 122 ptln(' <th class="userpass">'.$this->getLang('user_pass').'</th>'); 123 ptln(' <th class="username">'.$this->getLang('user_name').'</th>'); 124 ptln(' <th class="usermail">'.$this->getLang('user_mail').'</th>'); 125 ptln(' <th class="usergroups">'.$this->getLang('user_groups').'</th>'); 126 ptln(' </tr>'); 127 ptln(' </thead>'); 128 ptln(' <tbody>'); 129 foreach ($this->_import_failures as $line => $failure) { 130 ptln(' <tr>'); 131 ptln(' <td class="lineno"> '.sprintf('%0'.$digits.'d',$line).' </td>'); 132 ptln(' <td class="error">' .$failure['error'].' </td>'); 133 ptln(' <td class="field userid"> '.hsc($failure['user'][0]).' </td>'); 134 ptln(' <td class="field userpass"> '.hsc($failure['user'][1]).' </td>'); 135 ptln(' <td class="field username"> '.hsc($failure['user'][2]).' </td>'); 136 ptln(' <td class="field usermail"> '.hsc($failure['user'][3]).' </td>'); 137 ptln(' <td class="field usergroups"> '.hsc($failure['user'][4]).' </td>'); 138 ptln(' </tr>'); 139 } 140 ptln(' </tbody>'); 141 ptln(' </table>'); 142 ptln(' <p><a href="'.$failure_download_link.'">'.$this->getLang('import_downloadfailures').'</a></p>'); 143 ptln('</div>'); 144 } 145 } 146 147 /** 148 * Tries to set all defaults. Returns false if any of the required defaults are empty. 149 * 150 * @return bool 151 */ 152 protected function validateDefaults() 153 { 154 foreach ($this->defaults as $field) { 155 if (!in_array($field, self::DEFAULT_EMPTY) && empty($_REQUEST['defaults'][$field])) { 156 msg($this->getLang('error_required_defaults'), -1); 157 return false; 158 } 159 $this->defaults[$field] = $_REQUEST['defaults'][$field]; 160 161 // make sure groups include "user" 162 if ($field === 'groups' && strpos($_REQUEST['defaults'][$field], 'user') === false) { 163 $this->defaults[$field] .= ',user'; 164 } 165 } 166 return true; 167 } 168 169 /** 170 * Import a file of users in csv format 171 * 172 * csv file should have 5 columns, user_id, password, full name, email, groups (comma separated) 173 * 174 * @return bool whether successful 175 */ 176 protected function _import() { 177 // check we are allowed to add users 178 if (!checkSecurityToken()) return false; 179 if (!$this->_auth->canDo('addUser')) return false; 180 181 // check file uploaded ok. 182 $upl = $this->_isUploadedFile($_FILES['import']['tmp_name']); 183 if (empty($_FILES['import']['size']) || !empty($_FILES['import']['error']) && $upl) { 184 msg($this->getLang('import_error_upload'),-1); 185 return false; 186 } 187 // retrieve users from the file 188 $this->_import_failures = array(); 189 $import_success_count = 0; 190 $import_fail_count = 0; 191 $line = 0; 192 $fd = fopen($_FILES['import']['tmp_name'],'r'); 193 if ($fd) { 194 while($csv = fgets($fd)){ 195 if (!utf8_check($csv)) { 196 $csv = utf8_encode($csv); 197 } 198 $raw = str_getcsv($csv); 199 $error = ''; // clean out any errors from the previous line 200 // data checks... 201 if (1 == ++$line) { 202 if ($raw[0] == 'user_id' || $raw[0] == $this->getLang('user_id')) continue; // skip headers 203 } 204 // in contrast to User Manager, 5 columns are required 205 if (count($raw) < 5) { // need at least five fields 206 $import_fail_count++; 207 $error = sprintf($this->getLang('import_error_fields'), count($raw)); 208 $this->_import_failures[$line] = array('error' => $error, 'user' => $raw, 'orig' => $csv); 209 continue; 210 } 211 212 $clean = $this->_cleanImportUser($raw, $error); 213 if ($clean && $this->_addImportUser($clean, $error)) { 214 $sent = $this->_notifyUser($clean[0],$clean[1],false); 215 if (!$sent){ 216 msg(sprintf($this->getLang('import_notify_fail'),$clean[0],$clean[3]),-1); 217 } 218 $import_success_count++; 219 } else { 220 $import_fail_count++; 221 $this->_import_failures[$line] = array('error' => $error, 'user' => $raw, 'orig' => $csv); 222 } 223 } 224 msg(sprintf($this->getLang('import_success_count'), ($import_success_count+$import_fail_count), $import_success_count),($import_success_count ? 1 : -1)); 225 if ($import_fail_count) { 226 msg(sprintf($this->getLang('import_failure_count'), $import_fail_count),-1); 227 } 228 } else { 229 msg($this->getLang('import_error_readfail'),-1); 230 } 231 232 // save import failures into the session 233 if (!headers_sent()) { 234 session_start(); 235 $_SESSION['import_failures'] = $this->_import_failures; 236 session_write_close(); 237 } 238 return true; 239 } 240 241 /** 242 * Replaces empty values with defaults 243 * 244 * @param array $candidate 245 */ 246 protected function insertDefaults(&$candidate) 247 { 248 if (empty($candidate[1])) { 249 $candidate[1] = $this->defaults['password']; 250 } 251 if (empty($candidate[2])) { 252 $candidate[2] = $this->defaults['name']; 253 } 254 if (empty($candidate[3])) { 255 $candidate[3] = $this->defaults['email']; 256 } 257 if (empty($candidate[4])) { 258 $candidate[4] = $this->defaults['groups']; 259 } 260 } 261 262 /** 263 * Returns cleaned user data 264 * 265 * @param array $candidate raw values of line from input file 266 * @param string $error 267 * @return array|false cleaned data or false 268 */ 269 protected function _cleanImportUser($candidate, &$error) { 270 global $INPUT; 271 272 // fill in defaults if needed 273 $this->insertDefaults($candidate); 274 275 // kludgy .... 276 $INPUT->set('userid', $candidate[0]); 277 $INPUT->set('userpass', $candidate[1]); 278 $INPUT->set('username', $candidate[2]); 279 $INPUT->set('usermail', $candidate[3]); 280 $INPUT->set('usergroups', $candidate[4]); 281 282 $cleaned = $this->_retrieveUser(); 283 list($user,/* $pass */,$name,$mail,/* $grps */) = $cleaned; 284 if (empty($user)) { 285 $error = $this->getLang('import_error_baduserid'); 286 return false; 287 } 288 289 // no need to check password, handled elsewhere 290 291 if (!($this->_auth->canDo('modName') xor empty($name))){ 292 $error = $this->getLang('import_error_badname'); 293 return false; 294 } 295 296 if ($this->_auth->canDo('modMail')) { 297 if (empty($mail) || !mail_isvalid($mail)) { 298 $error = $this->getLang('import_error_badmail'); 299 return false; 300 } 301 } else { 302 if (!empty($mail)) { 303 $error = $this->getLang('import_error_badmail'); 304 return false; 305 } 306 } 307 308 return $cleaned; 309 } 310 311 /** 312 * Adds imported user to auth backend 313 * 314 * Required a check of canDo('addUser') before 315 * 316 * @param array $user data of user 317 * @param string &$error reference catched error message 318 * @return bool whether successful 319 */ 320 protected function _addImportUser($user, & $error){ 321 if (!$this->_auth->triggerUserMod('create', $user)) { 322 $error = $this->getLang('import_error_create'); 323 return false; 324 } 325 326 return true; 327 } 328 329 /** 330 * Retrieve & clean user data from the form 331 * 332 * @param bool $clean whether the cleanUser method of the authentication backend is applied 333 * @return array (user, password, full name, email, array(groups)) 334 */ 335 protected function _retrieveUser($clean=true) { 336 /** @var DokuWiki_Auth_Plugin $auth */ 337 global $auth; 338 global $INPUT; 339 340 $user = []; 341 $user[0] = ($clean) ? $auth->cleanUser($INPUT->str('userid')) : $INPUT->str('userid'); 342 $user[1] = $INPUT->str('userpass'); 343 $user[2] = $INPUT->str('username'); 344 $user[3] = $INPUT->str('usermail'); 345 $user[4] = explode(',',$INPUT->str('usergroups')); 346 $user[5] = $INPUT->str('userpass2'); // repeated password for confirmation 347 348 $user[4] = array_map('trim',$user[4]); 349 if($clean) $user[4] = array_map(array($auth,'cleanGroup'),$user[4]); 350 $user[4] = array_filter($user[4]); 351 $user[4] = array_unique($user[4]); 352 if(!count($user[4])) $user[4] = null; 353 354 return $user; 355 } 356 357 /** 358 * Send password change notification email 359 * 360 * @param string $user id of user 361 * @param string $password plain text 362 * @param bool $status_alert whether status alert should be shown 363 * @return bool whether succesful 364 */ 365 protected function _notifyUser($user, $password, $status_alert=true) { 366 $sent = auth_sendPassword($user,$password); 367 if ($sent) { 368 if ($status_alert) { 369 msg($this->getLang('notify_ok'), 1); 370 } 371 } else { 372 if ($status_alert) { 373 msg($this->getLang('notify_fail'), -1); 374 } 375 } 376 377 return $sent; 378 } 379 380 /** 381 * Downloads failures as csv file 382 */ 383 protected function _downloadImportFailures(){ 384 385 // ============================================================================================== 386 // GENERATE OUTPUT 387 // normal headers for downloading... 388 header('Content-type: text/csv;charset=utf-8'); 389 header('Content-Disposition: attachment; filename="importfails.csv"'); 390# // for debugging assistance, send as text plain to the browser 391# header('Content-type: text/plain;charset=utf-8'); 392 393 // output the csv 394 $fd = fopen('php://output','w'); 395 foreach ($this->_import_failures as $fail) { 396 fputs($fd, $fail['orig']); 397 } 398 fclose($fd); 399 die; 400 } 401 402 /** 403 * wrapper for is_uploaded_file to facilitate overriding by test suite 404 * 405 * @param string $file filename 406 * @return bool 407 */ 408 protected function _isUploadedFile($file) { 409 return is_uploaded_file($file); 410 } 411} 412