uploadmanager.php 16 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
        if (get_config('viruschecking')) {
123
124
            if ($errormsg = mahara_clam_scan_file($file, $this->inputindex)) {
                return $errormsg;
125
            }
Richard Mansfield's avatar
Richard Mansfield committed
126
127
128
        }

        $this->file = $file;
129
        return false;
Richard Mansfield's avatar
Richard Mansfield committed
130
131
    }

132

133
    /**
Richard Mansfield's avatar
Richard Mansfield committed
134
135
136
137
138
     * 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
139
     * @return false if no errors, or a string describing the error
Richard Mansfield's avatar
Richard Mansfield committed
140
     */
141
142
143
144
145
146
    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
147
148

        if (!isset($this->file)) {
Richard Mansfield's avatar
Richard Mansfield committed
149
            return get_string('unknownerror');
Richard Mansfield's avatar
Richard Mansfield committed
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
        }

        $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)) {
170
            throw new UploadException('Unable to create upload directory');
Richard Mansfield's avatar
Richard Mansfield committed
171
172
        }

173
        if (file_exists($destination . '/' . $newname) && $this->handlecollisions) {
Richard Mansfield's avatar
Richard Mansfield committed
174
175
176
            $newname = $this->rename_duplicate_file($destination, $newname);
        }

177
178
179
180
181
182
183
        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
184
            chmod($destination . '/' . $newname, get_config('filepermissions'));
Richard Mansfield's avatar
Richard Mansfield committed
185
            return false;
Richard Mansfield's avatar
Richard Mansfield committed
186
        }
187
        return get_string('failedmovingfiletodataroot', 'mahara');
Richard Mansfield's avatar
Richard Mansfield committed
188
    }
189

Richard Mansfield's avatar
Richard Mansfield committed
190
191
192
193

    /**
     * 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.
194
195
196
     * @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.
197
198
     */
    public function process_file_upload($destination, $newname) {
Richard Mansfield's avatar
Richard Mansfield committed
199
200
        $error = $this->preprocess_file();
        if (!$error) {
Richard Mansfield's avatar
Richard Mansfield committed
201
202
            return $this->save_file($destination, $newname);
        }
Richard Mansfield's avatar
Richard Mansfield committed
203
        return $error;
Richard Mansfield's avatar
Richard Mansfield committed
204
205
206
207
208
209
210
211
212
    }

    /**
     * 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.
     */
213
    public function rename_duplicate_file($destination, $filename, $format='%s_%d.%s') {
Richard Mansfield's avatar
Richard Mansfield committed
214
        // If there's no dot or more than one dot we get yucky stuff like 'foo_1.', 'foo_1.bar.baz'
215
        $bits = explode('.', $filename);
Richard Mansfield's avatar
Richard Mansfield committed
216
217
218
219
220
221
222
223
224
        // 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;
            }
        }
    }

225
    public function original_filename_extension() {
226
227
228
229
230
231
232
233
234
235
236
        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)) {
237
            return strtolower($m[1]);
238
239
240
241
242
        }
        return null;
    }


Richard Mansfield's avatar
Richard Mansfield committed
243
244
245
246
247
248
249
250
}

/**************************************************************************************
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
251
/**
252
 * Deals with an infected file - either moves it to a quarantinedir
Richard Mansfield's avatar
Richard Mansfield committed
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
 * (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');

268
    $quarantinedir = get_config('dataroot') . 'quarantine';
Richard Mansfield's avatar
Richard Mansfield committed
269
270
271
272
273
    check_dir_exists($quarantinedir);

    if (is_dir($quarantinedir) && is_writable($quarantinedir)) {
        $now = date('YmdHis');
        $newname = $quarantinedir .'/'. $now .'-user-'. $userid .'-infected';
274
        if (rename($file, $newname)) {
275
            return clam_log_infected($file, $newname);
Richard Mansfield's avatar
Richard Mansfield committed
276
277
278
279
280
281
282
283
284
285
286
        }
    }
    if (unlink($file)) {
        clam_log_infected($file, '', $userid);
        return get_string('clamdeletedfile');
    }
    return get_string('clamdeletefilefailed');
}


/**
287
 * Scan a file for viruses using clamav.
Richard Mansfield's avatar
Richard Mansfield committed
288
289
290
 *
 * @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.
291
 */
292
function mahara_clam_scan_file($file, $inputindex=null) {
Richard Mansfield's avatar
Richard Mansfield committed
293

294
295
296
297
298
299
300
    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
301
        $fullpath = $tmpname;
Richard Mansfield's avatar
Richard Mansfield committed
302
    }
303
    else if (file_exists($file)) {
Richard Mansfield's avatar
Richard Mansfield committed
304
305
306
        $fullpath = $file;
    }
    else {
307
        throw new SystemException('mahara_clam_scan_file: not called correctly, read phpdoc for this function');
Richard Mansfield's avatar
Richard Mansfield committed
308
309
    }

310
    $pathtoclam = escapeshellcmd(trim(get_config('pathtoclam')));
Richard Mansfield's avatar
Richard Mansfield committed
311

312
313
314
315
316
    if (!$pathtoclam) {
        return false;
    }

    if (!file_exists($pathtoclam) || !is_executable($pathtoclam)) {
Richard Mansfield's avatar
Richard Mansfield committed
317
318
319
320
        clam_mail_admins(get_string('clamlost', 'mahara', $pathtoclam));
        clam_handle_infected_file($fullpath);
        return get_string('clambroken');
    }
321
322
323
324
325
326
327
328
329
330
    $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
331
332
333
334
335
336
337
338
339

    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');
340
        clam_handle_infected_file($fullpath);
Richard Mansfield's avatar
Richard Mansfield committed
341
342
        // Notify admins if user has uploaded more than 3 infected
        // files in the last month
343
344
345
346
347
348
        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
349
            log_debug('sending virusrepeat notification');
350
351
352
            $data = (object) array('username' => $USER->get('username'),
                                   'userid' => $userid,
                                   'fullname' => full_name());
Richard Mansfield's avatar
Richard Mansfield committed
353
354
355
356
357
358
            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));
359
    default:
Richard Mansfield's avatar
Richard Mansfield committed
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
        // 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) {
380
381
382
383
384
385
386
387
            $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
388
389
390
391
392
393
394
395
396
397
398
399
400
401
        }
    }
}

/**
 * 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.';
402
    $returncodes[2] = ' An error occurred'; // specific to clamdscan
Richard Mansfield's avatar
Richard Mansfield committed
403
404
405
406
407
408
409
410
411
412
413
414
    // 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.';
415
    $returncodes[61] = 'Can\'t fork.';
Richard Mansfield's avatar
Richard Mansfield committed
416
417
418
419
    $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).';
420
    if (isset($returncodes[$returncode])) {
Richard Mansfield's avatar
Richard Mansfield committed
421
       return $returncodes[$returncode];
422
    }
Richard Mansfield's avatar
Richard Mansfield committed
423
424
    return get_string('clamunknownerror');

Richard Mansfield's avatar
Richard Mansfield committed
425
426
}

Richard Mansfield's avatar
Richard Mansfield committed
427
428
429
430
431
432
433
434
435
436
437
438
439
440
/**
 * 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()
441
        . ((empty($oldfilepath)) ? '. The infected file was caught on upload ('.$oldfilepath.')'
Richard Mansfield's avatar
Richard Mansfield committed
442
443
444
           : '. 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);

445
    log_debug($errorstr);
446
    return $errorstr;
Richard Mansfield's avatar
Richard Mansfield committed
447
}