uploadmanager.php 16.3 KB
Newer Older
Richard Mansfield's avatar
Richard Mansfield committed
1
<?php
2 3 4 5 6
/**
 *
 * @package    mahara
 * @subpackage core
 * @author     Martin Dougiamas <martin@moodle.com>
7 8
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL version 3 or later
 * @copyright  For copyright information on Mahara, please see the README file distributed with this software.
9 10 11 12
 * @copyright  (C) 2001-3001 Martin Dougiamas http://dougiamas.com
 *
 */

13
defined('INTERNAL') || die();
Richard Mansfield's avatar
Richard Mansfield committed
14 15 16 17 18 19 20

class upload_manager {

   /**
    * Array to hold local copy of stuff in $_FILES
    * @var array $file
    */
21
    public $file;
Richard Mansfield's avatar
Richard Mansfield committed
22 23 24

   /**
    * Name of the file input
25
    * @var string $inputname
Richard Mansfield's avatar
Richard Mansfield committed
26
    */
27
    public $inputname;
Richard Mansfield's avatar
Richard Mansfield committed
28 29 30 31 32

   /**
    * Whether to try to rename files when they already exist
    * @var bool $handlecollisions
    */
33 34 35 36 37 38 39 40 41 42 43 44 45
    public $handlecollisions;

    /**
     * Indicates whether this file is optional to the form.
     * @var bool $optional
     */
    public $optional;

    /**
     * Indicates that the file input was optional and was skipped on purpose.
     * @var bool $optional
     */
    public $optionalandnotsupplied = false;
Richard Mansfield's avatar
Richard Mansfield committed
46 47 48 49 50 51

    /**
     * Constructor.
     *
     * @param string $inputname Name in $_FILES.
     */
52
    public function __construct($inputname, $handlecollisions=false, $inputindex=null, $optional=false) {
Richard Mansfield's avatar
Richard Mansfield committed
53 54
        $this->inputname = $inputname;
        $this->handlecollisions = $handlecollisions;
55
        $this->inputindex = $inputindex;
56
        $this->optional = $optional;
Richard Mansfield's avatar
Richard Mansfield committed
57 58
    }

59
    /**
Richard Mansfield's avatar
Richard Mansfield committed
60 61 62
     * Gets file information out of $_FILES and stores it locally in $files.
     * Checks file against max upload file size.
     * Scans file for viruses.
Richard Mansfield's avatar
Richard Mansfield committed
63
     * @return false for no errors, or a string describing the error
Richard Mansfield's avatar
Richard Mansfield committed
64
     */
65
    public function preprocess_file() {
Richard Mansfield's avatar
Richard Mansfield committed
66 67 68

        $name = $this->inputname;
        if (!isset($_FILES[$name])) {
69 70 71 72 73
            if ($this->optional) {
                $this->optionalandnotsupplied = true;
                return false;
            }
            else {
74
                return get_string('noinputnamesupplied', 'mahara');
75
            }
Richard Mansfield's avatar
Richard Mansfield committed
76 77
        }

78
        $file = $_FILES[$name];
79
        $maxsize = get_config('maxuploadsize');
80 81 82 83 84 85 86 87 88 89 90
        if (isset($this->inputindex)) {
            $size = $file['size'][$this->inputindex];
            $error = $file['error'][$this->inputindex];
            $tmpname = $file['tmp_name'][$this->inputindex];
        }
        else {
            $size = $file['size'];
            $error = $file['error'];
            $tmpname = $file['tmp_name'];
        }
        if ($maxsize && $size > $maxsize) {
91
            return get_string('uploadedfiletoobig1', 'mahara', display_size(get_max_upload_size(false)));
92 93
        }

94 95 96
        if ($error != UPLOAD_ERR_OK) {
            $errormsg = get_string('phpuploaderror', 'mahara', get_string('phpuploaderror_' . $error), $error);
            if ($error == UPLOAD_ERR_NO_TMP_DIR || $error == UPLOAD_ERR_CANT_WRITE) {
97 98 99 100 101 102 103 104 105 106
                // The admin probably needs to fix this; notify them
                // @TODO: Create a new activity type for general admin messages.
                $message = (object) array(
                    'users' => get_column('usr', 'id', 'admin', 1),
                    'subject' => get_string('adminphpuploaderror'),
                    'message' => $errormsg,
                );
                require_once('activity.php');
                activity_occurred('maharamessage', $message);
            }
107
            else if ($error == UPLOAD_ERR_INI_SIZE || $error == UPLOAD_ERR_FORM_SIZE) {
108
                return get_string('uploadedfiletoobig1', 'mahara', display_size(get_max_upload_size(false)));
109
            }
110 111
        }

112
        if (!is_uploaded_file($tmpname)) {
113 114 115 116 117 118 119
            if ($this->optional) {
                $this->optionalandnotsupplied = true;
                return false;
            }
            else {
                return get_string('notphpuploadedfile');
            }
Richard Mansfield's avatar
Richard Mansfield committed
120
        }
Richard Mansfield's avatar
Richard Mansfield committed
121

122 123 124
        if (get_config('viruschecking')) {
            $pathtoclam = escapeshellcmd(trim(get_config('pathtoclam')));
            if ($pathtoclam && file_exists($pathtoclam) && is_executable($pathtoclam)) {
125
                if ($errormsg = mahara_clam_scan_file($file, $this->inputindex)) {
126 127 128
                    return $errormsg;
                }
            }
129 130 131
            else {
                clam_mail_admins(get_string('clamlost', 'mahara', $pathtoclam));
            }
Richard Mansfield's avatar
Richard Mansfield committed
132 133 134
        }

        $this->file = $file;
135
        return false;
Richard Mansfield's avatar
Richard Mansfield committed
136 137
    }

138

139
    /**
Richard Mansfield's avatar
Richard Mansfield committed
140 141 142 143 144
     * Moves the file to the destination directory.
     *
     * @uses $CFG
     * @param string $destination The destination directory.
     * @param string $newname The filename
Richard Mansfield's avatar
Richard Mansfield committed
145
     * @return false if no errors, or a string describing the error
Richard Mansfield's avatar
Richard Mansfield committed
146
     */
147 148 149 150 151 152
    public function save_file($destination, $newname) {

        // It's an optional file that was not uploaded.
        if ($this->optional && $this->optionalandnotsupplied) {
            return false;
        }
Richard Mansfield's avatar
Richard Mansfield committed
153 154

        if (!isset($this->file)) {
Richard Mansfield's avatar
Richard Mansfield committed
155
            return get_string('unknownerror');
Richard Mansfield's avatar
Richard Mansfield committed
156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175
        }

        $dataroot = get_config('dataroot');

        if (!(strpos($destination, $dataroot) === false)) {
            // take it out for giving to make_upload_directory
            $destination = substr($destination, strlen($dataroot)+1);
        }

        if ($destination{strlen($destination)-1} == '/') { // strip off a trailing / if we have one
            $destination = substr($destination, 0, -1);
        }

        if (empty($destination)) {
            $destination = $dataroot;
        } else {
            $destination = $dataroot . '/' . $destination;
        }

        if (!check_dir_exists($destination, true, true)) {
176
            throw new UploadException('Unable to create upload directory');
Richard Mansfield's avatar
Richard Mansfield committed
177 178
        }

179
        if (file_exists($destination . '/' . $newname) && $this->handlecollisions) {
Richard Mansfield's avatar
Richard Mansfield committed
180 181 182
            $newname = $this->rename_duplicate_file($destination, $newname);
        }

183 184 185 186 187 188 189
        if (isset($this->inputindex)) {
            $tmpname = $this->file['tmp_name'][$this->inputindex];
        }
        else {
            $tmpname = $this->file['tmp_name'];
        }
        if (move_uploaded_file($tmpname, $destination . '/' . $newname)) {
Hugh Davenport's avatar
Hugh Davenport committed
190
            chmod($destination . '/' . $newname, get_config('filepermissions'));
Richard Mansfield's avatar
Richard Mansfield committed
191
            return false;
Richard Mansfield's avatar
Richard Mansfield committed
192
        }
193
        return get_string('failedmovingfiletodataroot', 'mahara');
Richard Mansfield's avatar
Richard Mansfield committed
194
    }
195

Richard Mansfield's avatar
Richard Mansfield committed
196 197 198 199

    /**
     * Wrapper function that calls {@link preprocess_files()} and {@link viruscheck_files()} and then {@link save_files()}
     * Modules that require the insert id in the filepath should not use this and call these functions seperately in the required order.
200 201 202
     * @parameter string $destination Where to save the uploaded file to.
     * @parameter string $newname What to call the saved file.
     * @return false for no errors, or a string describing the error.
203 204
     */
    public function process_file_upload($destination, $newname) {
Richard Mansfield's avatar
Richard Mansfield committed
205 206
        $error = $this->preprocess_file();
        if (!$error) {
Richard Mansfield's avatar
Richard Mansfield committed
207 208
            return $this->save_file($destination, $newname);
        }
Richard Mansfield's avatar
Richard Mansfield committed
209
        return $error;
Richard Mansfield's avatar
Richard Mansfield committed
210 211 212 213 214 215 216 217 218
    }

    /**
     * Handles filename collisions - if the desired filename exists it will rename it according to the pattern in $format
     * @param string $destination Destination directory (to check existing files against)
     * @param object $file Passed in by reference. The current file from $files we're processing.
     * @param string $format The printf style format to rename the file to (defaults to filename_number.extn)
     * @return string The new filename.
     */
219
    public function rename_duplicate_file($destination, $filename, $format='%s_%d.%s') {
Richard Mansfield's avatar
Richard Mansfield committed
220
        // If there's no dot or more than one dot we get yucky stuff like 'foo_1.', 'foo_1.bar.baz'
221
        $bits = explode('.', $filename);
Richard Mansfield's avatar
Richard Mansfield committed
222 223 224 225 226 227 228 229 230
        // check for collisions and append a nice numberydoo.
        for ($i = 1; true; $i++) {
            $try = sprintf($format, $bits[0], $i, $bits[1]);
            if (!file_exists($destination . '/' . $try)) {
                return $try;
            }
        }
    }

231
    public function original_filename_extension() {
232 233 234 235 236 237 238 239 240 241 242
        if (isset($this->file)) {
            if (isset($this->inputindex)) {
                $filename = $this->file['name'][$this->inputindex];
            }
            else {
                $filename = $this->file['name'];
            }
        }
        if (isset($filename)
            && !empty($filename)
            && preg_match("/\.([^\.]+)$/", $filename, $m)) {
243
            return strtolower($m[1]);
244 245 246 247 248
        }
        return null;
    }


Richard Mansfield's avatar
Richard Mansfield committed
249 250 251 252 253 254 255 256
}

/**************************************************************************************
THESE FUNCTIONS ARE OUTSIDE THE CLASS BECAUSE THEY NEED TO BE CALLED FROM OTHER PLACES.
FOR EXAMPLE CLAM_HANDLE_INFECTED_FILE AND CLAM_REPLACE_INFECTED_FILE USED FROM CRON
UPLOAD_PRINT_FORM_FRAGMENT DOESN'T REALLY BELONG IN THE CLASS BUT CERTAINLY IN THIS FILE
***************************************************************************************/

Richard Mansfield's avatar
Richard Mansfield committed
257
/**
258
 * Deals with an infected file - either moves it to a quarantinedir
Richard Mansfield's avatar
Richard Mansfield committed
259 260 261 262 263 264 265 266 267 268 269 270 271 272 273
 * (specified in CFG->quarantinedir) or deletes it.
 *
 * If moving it fails, it deletes it.
 *
 *@uses $CFG
 * @uses $USER
 * @param string $file Full path to the file
 * @param int $userid If not used, defaults to $USER->id (there in case called from cron)
 * @param boolean $basiconly Admin level reporting or user level reporting.
 * @return string Details of what the function did.
 */
function clam_handle_infected_file($file) {
    global $USER;
    $userid = $USER->get('id');

274
    $quarantinedir = get_config('dataroot') . 'quarantine';
Richard Mansfield's avatar
Richard Mansfield committed
275 276 277 278 279
    check_dir_exists($quarantinedir);

    if (is_dir($quarantinedir) && is_writable($quarantinedir)) {
        $now = date('YmdHis');
        $newname = $quarantinedir .'/'. $now .'-user-'. $userid .'-infected';
280
        if (rename($file, $newname)) {
Richard Mansfield's avatar
Richard Mansfield committed
281 282 283 284 285 286 287 288 289 290 291 292 293
            clam_log_infected($file, $newname);
            return get_string('clammovedfile');
        }
    }
    if (unlink($file)) {
        clam_log_infected($file, '', $userid);
        return get_string('clamdeletedfile');
    }
    return get_string('clamdeletefilefailed');
}


/**
294
 * Scan a file for viruses using clamav.
Richard Mansfield's avatar
Richard Mansfield committed
295 296 297
 *
 * @param mixed $file The file to scan from $files. or an absolute path to a file.
 * @return false if no errors, or a string if there's an error.
298
 */
299
function mahara_clam_scan_file($file, $inputindex=null) {
Richard Mansfield's avatar
Richard Mansfield committed
300

301 302 303 304 305 306 307
    if (isset($inputindex)) {
        $tmpname = $file['tmp_name'][$inputindex];
    }
    else if (is_array($file)) {
        $tmpname = $file['tmp_name'];
    }
    if (is_array($file) && is_uploaded_file($tmpname)) { // it's from $_FILES
308
        $fullpath = $tmpname;
Richard Mansfield's avatar
Richard Mansfield committed
309
    }
310
    else if (file_exists($file)) {
Richard Mansfield's avatar
Richard Mansfield committed
311 312 313
        $fullpath = $file;
    }
    else {
314
        throw new SystemException('mahara_clam_scan_file: not called correctly, read phpdoc for this function');
Richard Mansfield's avatar
Richard Mansfield committed
315 316
    }

317
    $pathtoclam = escapeshellcmd(trim(get_config('pathtoclam')));
Richard Mansfield's avatar
Richard Mansfield committed
318

319 320 321 322 323
    if (!$pathtoclam) {
        return false;
    }

    if (!file_exists($pathtoclam) || !is_executable($pathtoclam)) {
Richard Mansfield's avatar
Richard Mansfield committed
324 325 326 327
        clam_mail_admins(get_string('clamlost', 'mahara', $pathtoclam));
        clam_handle_infected_file($fullpath);
        return get_string('clambroken');
    }
328 329 330 331 332 333 334 335 336 337
    $clamparam = ' ';
    // If we are dealing with clamdscan, clamd is likely run as a different user
    // that might not have permissions to access your file.
    // To make clamdscan work, we use --fdpass parameter that passes the file
    // descriptor permissions to clamd, which allows it to scan given file
    // irrespective of directory and file permissions.
    if (basename($pathtoclam) == 'clamdscan') {
        $clamparam .= '--fdpass ';
    }
    $cmd = $pathtoclam . $clamparam . escapeshellarg($fullpath) ." 2>&1";
Richard Mansfield's avatar
Richard Mansfield committed
338 339 340 341 342 343 344 345 346

    exec($cmd, $output, $return);

    switch ($return) {
    case 0: // glee! we're ok.
        return false; // no error
    case 1:  // bad wicked evil, we have a virus.
        global $USER;
        $userid = $USER->get('id');
347
        clam_handle_infected_file($fullpath);
Richard Mansfield's avatar
Richard Mansfield committed
348 349
        // Notify admins if user has uploaded more than 3 infected
        // files in the last month
350 351 352 353 354 355
        if (count_records_sql('
            SELECT
                COUNT(*)
            FROM {usr_infectedupload}
            WHERE usr = ? AND time > ?',
            array($userid, db_format_timestamp(time() - 60*60*24*30))) >= 2) {
Richard Mansfield's avatar
Richard Mansfield committed
356
            log_debug('sending virusrepeat notification');
357 358 359
            $data = (object) array('username' => $USER->get('username'),
                                   'userid' => $userid,
                                   'fullname' => full_name());
Richard Mansfield's avatar
Richard Mansfield committed
360 361 362 363 364 365
            require_once('activity.php');
            activity_occurred('virusrepeat', $data);
        }
        $data = (object) array('usr' => $userid, 'time' => db_format_timestamp(time()));
        insert_record('usr_infectedupload', $data, 'id');
        return get_string('virusfounduser', 'mahara', display_name($USER));
366
    default:
Richard Mansfield's avatar
Richard Mansfield committed
367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386
        // error - clam failed to run or something went wrong
        $notice = get_string('clamfailed', 'mahara', get_clam_error_code($return));
        $notice .= "\n\n". implode("\n", $output);
        $notice .= "\n". clam_handle_infected_file($fullpath);
        clam_mail_admins($notice);
        return get_string('clambroken');
    }

}

/**
 * Emails admins about a clam outcome
 *
 * @param string $notice The body of the email to be sent.
 */
function clam_mail_admins($notice) {
    $subject = get_string('clamemailsubject', 'mahara', get_config('sitename'));
    $adminusers = get_records_array('usr', 'admin', 1);
    if ($adminusers) {
        foreach ($adminusers as $admin) {
387 388 389 390 391 392 393 394
            $message = new StdClass;
            $message->users = array($admin->id);

            $message->subject = $subject;
            $message->message = $notice;

            require_once('activity.php');
            activity_occurred('maharamessage', $message);
Richard Mansfield's avatar
Richard Mansfield committed
395 396 397 398 399 400 401 402 403 404 405 406 407 408
        }
    }
}

/**
 * Returns the string equivalent of a numeric clam error code
 *
 * @param int $returncode The numeric error code in question.
 * return string The definition of the error code
 */
function get_clam_error_code($returncode) {
    $returncodes = array();
    $returncodes[0] = 'No virus found.';
    $returncodes[1] = 'Virus(es) found.';
409
    $returncodes[2] = ' An error occurred'; // specific to clamdscan
Richard Mansfield's avatar
Richard Mansfield committed
410 411 412 413 414 415 416 417 418 419 420 421
    // all after here are specific to clamscan
    $returncodes[40] = 'Unknown option passed.';
    $returncodes[50] = 'Database initialization error.';
    $returncodes[52] = 'Not supported file type.';
    $returncodes[53] = 'Can\'t open directory.';
    $returncodes[54] = 'Can\'t open file. (ofm)';
    $returncodes[55] = 'Error reading file. (ofm)';
    $returncodes[56] = 'Can\'t stat input file / directory.';
    $returncodes[57] = 'Can\'t get absolute path name of current working directory.';
    $returncodes[58] = 'I/O error, please check your filesystem.';
    $returncodes[59] = 'Can\'t get information about current user from /etc/passwd.';
    $returncodes[60] = 'Can\'t get information about user \'clamav\' (default name) from /etc/passwd.';
422
    $returncodes[61] = 'Can\'t fork.';
Richard Mansfield's avatar
Richard Mansfield committed
423 424 425 426
    $returncodes[63] = 'Can\'t create temporary files/directories (check permissions).';
    $returncodes[64] = 'Can\'t write to temporary directory (please specify another one).';
    $returncodes[70] = 'Can\'t allocate and clear memory (calloc).';
    $returncodes[71] = 'Can\'t allocate memory (malloc).';
427
    if (isset($returncodes[$returncode])) {
Richard Mansfield's avatar
Richard Mansfield committed
428
       return $returncodes[$returncode];
429
    }
Richard Mansfield's avatar
Richard Mansfield committed
430 431
    return get_string('clamunknownerror');

Richard Mansfield's avatar
Richard Mansfield committed
432 433
}

Richard Mansfield's avatar
Richard Mansfield committed
434 435 436 437 438 439 440 441 442 443 444 445 446 447
/**
 * This function logs to error_log and to the log table that an infected file has been found and what's happened to it.
 *
 * @param string $oldfilepath Full path to the infected file before it was moved.
 * @param string $newfilepath Full path to the infected file since it was moved to the quarantine directory (if the file was deleted, leave empty).
 * @param int $userid The user id of the user who uploaded the file.
 */
function clam_log_infected($oldfilepath='', $newfilepath='', $userid=0) {

    global $USER;
    $username = $USER->get('username') . ' (' . full_name() . ')';

    $errorstr = 'Clam AV has found a file that is infected with a virus. It was uploaded by '
        . full_name()
448
        . ((empty($oldfilepath)) ? '. The infected file was caught on upload ('.$oldfilepath.')'
Richard Mansfield's avatar
Richard Mansfield committed
449 450 451
           : '. The original file path of the infected file was '. $oldfilepath)
        . ((empty($newfilepath)) ? '. The file has been deleted ' : '. The file has been moved to a quarantine directory and the new path is '. $newfilepath);

452
    log_debug($errorstr);
Richard Mansfield's avatar
Richard Mansfield committed
453
}