MDL-14589 initial file storage implementation, temporary file manager, migration...
[moodle.git] / lib / file / file_storage.php
CommitLineData
172dd12c 1<?php //$Id$
2
3require_once("$CFG->libdir/file/stored_file.php");
4
5class file_storage {
6 private $filedir;
7
8 /**
9 * Contructor
10 * @param string $filedir full path to pool directory
11 */
12 public function __construct() {
13 global $CFG;
14 if (isset($CFG->filedir)) {
15 $this->filedir = $CFG->filedir;
16 } else {
17 $this->filedir = $CFG->dataroot.'/filedir';
18 }
19
20 // make sure the file pool directory exists
21 if (!is_dir($this->filedir)) {
22 if (!check_dir_exists($this->filedir, true, true)) {
23 throw new file_exception('localfilecannotcreatefiledirs'); // permission trouble
24 }
25 // place warning file in file pool root
26 $fp = fopen($this->filedir.'/warning.txt', 'w');
27 fwrite($fp, 'This directory contains the content of uploaded files and is controlled by Moodle code. Do not manually move, change or rename any of the files and subdirectories here.');
28 fclose($fp);
29 unset($fp);
30 }
31 }
32
33 /**
34 * Calculates sha1 hash of unique full path name information
35 * @param int $contextid
36 * @param string $filearea
37 * @param int $itemid
38 * @param string $filepath
39 * @param string $filename
40 * @return string
41 */
42 public static function get_pathname_hash($contextid, $filearea, $itemid, $filepath, $filename) {
43 return sha1($contextid.$filearea.$itemid.$filepath.$filename);
44 }
45
46 /**
47 * Does this file exist?
48 * @param int $contextid
49 * @param string $filearea
50 * @param int $itemid
51 * @param string $filepath
52 * @param string $filename
53 * @return bool
54 */
55 public function file_exists($contextid, $filearea, $itemid, $filepath, $filename) {
56 $filepath = clean_param($filepath, PARAM_PATH);
57 $filename = clean_param($filename, PARAM_FILE);
58
59 if ($filename === '') {
60 $filename = '.';
61 }
62
63 $pathnamehash = $this->get_pathname_hash($contextid, $filearea, $itemid, $filepath, $filename);
64 return $this->file_exists_by_hash($pathnamehash);
65 }
66
67 /**
68 * Does this file exist?
69 * @param string $pathnamehash
70 * @return bool
71 */
72 public function file_exists_by_hash($pathnamehash) {
73 global $DB;
74
75 return $DB->record_exists('files', array('pathnamehash'=>$pathnamehash));
76 }
77
78 /**
79 * Fetch file using local file id
80 * @param int $fileid
81 * @return mixed stored_file instance if exists, false if not
82 */
83 public function get_file_by_id($fileid) {
84 global $DB;
85
86 if ($file_record = $DB->get_record('files', array('id'=>$fileid))) {
87 return new stored_file($this, $file_record);
88 } else {
89 return false;
90 }
91 }
92
93 /**
94 * Fetch file using local file full pathname hash
95 * @param string $pathnamehash
96 * @return mixed stored_file instance if exists, false if not
97 */
98 public function get_file_by_hash($pathnamehash) {
99 global $DB;
100
101 if ($file_record = $DB->get_record('files', array('pathnamehash'=>$pathnamehash))) {
102 return new stored_file($this, $file_record);
103 } else {
104 return false;
105 }
106 }
107
108 /**
109 * Fetch file
110 * @param int $contextid
111 * @param string $filearea
112 * @param int $itemid
113 * @param string $filepath
114 * @param string $filename
115 * @return mixed stored_file instance if exists, false if not
116 */
117 public function get_file($contextid, $filearea, $itemid, $filepath, $filename) {
118 global $DB;
119
120 $filepath = clean_param($filepath, PARAM_PATH);
121 $filename = clean_param($filename, PARAM_FILE);
122
123 if ($filename === '') {
124 $filename = '.';
125 }
126
127 $pathnamehash = $this->get_pathname_hash($contextid, $filearea, $itemid, $filepath, $filename);
128 return $this->get_file_by_hash($pathnamehash);
129 }
130
131 /**
132 * Returns all area files (optionally limited by itemid)
133 * @param int $contextid
134 * @param string $filearea
135 * @param int $itemid (all files if not specified)
136 * @param string $sort
137 * @param bool $includedirs
138 * @return array of stored_files
139 */
140 public function get_area_files($contextid, $filearea, $itemid=false, $sort="itemid, filepath, filename", $inludedirs=true) {
141 global $DB;
142
143 $conditions = array('contextid'=>$contextid, 'filearea'=>$filearea);
144 if ($itemid !== false) {
145 $conditions['itemid'] = $itemid;
146 }
147
148 $result = array();
149 $file_records = $DB->get_records('files', $conditions, $sort);
150 foreach ($file_records as $file_record) {
151 if (!$inludedirs and $file_record->filename === '.') {
152 continue;
153 }
154 $result[] = new stored_file($this, $file_record);
155 }
156 return $result;
157 }
158
159 /**
160 * Delete all area files (optionally limited by itemid)
161 * @param int $contextid
162 * @param string $filearea
163 * @param int $itemid (all files if not specified)
164 * @return success
165 */
166 public function delete_area_files($contextid, $filearea, $itemid=false) {
167 global $DB;
168
169 $conditions = array('contextid'=>$contextid, 'filearea'=>$filearea);
170 if ($itemid !== false) {
171 $conditions['itemid'] = $itemid;
172 }
173
174 $success = true;
175
176 $file_records = $DB->get_records('files', $conditions);
177 foreach ($file_records as $file_record) {
178 $stored_file = new stored_file($this, $file_record);
179 $success = $stored_file->delete() && $success;
180 }
181
182 return $success;
183 }
184
185 /**
186 * Recursively creates director
187 * @param int $contextid
188 * @param string $filearea
189 * @param int $itemid
190 * @param string $filepath
191 * @param string $filename
192 * @return bool success
193 */
194 public function create_directory($contextid, $filearea, $itemid, $filepath, $userid=null) {
195 global $DB;
196
197 // validate all parameters, we do not want any rubbish stored in database, right?
198 if (!is_number($contextid) or $contextid < 1) {
199 throw new file_exception('localfileproblem', 'Invalid contextid');
200 }
201
202 $filearea = clean_param($filearea, PARAM_SAFEDIR);
203 if ($filearea === '') {
204 throw new file_exception('localfileproblem', 'Invalid filearea');
205 }
206
207 if (!is_number($itemid) or $itemid < 0) {
208 throw new file_exception('localfileproblem', 'Invalid itemid');
209 }
210
211 $filepath = clean_param($filepath, PARAM_PATH);
212 if (strpos($filepath, '/') !== 0 or strrpos($filepath, '/') !== strlen($filepath)-1) {
213 // path must start and end with '/'
214 throw new file_exception('localfileproblem', 'Invalid file path');
215 }
216
217 $pathnamehash = $this->get_pathname_hash($contextid, $filearea, $itemid, $filepath, '.');
218
219 if ($dir_info = $this->get_file_by_hash($pathnamehash)) {
220 return $dir_info;
221 }
222
223 static $contenthash = null;
224 if (!$contenthash) {
225 $this->add_to_pool_string('');
226 $contenthash = sha1('');
227 }
228
229 $now = time();
230
231 $dir_record = new object();
232 $dir_record->contextid = $contextid;
233 $dir_record->filearea = $filearea;
234 $dir_record->itemid = $itemid;
235 $dir_record->filepath = $filepath;
236 $dir_record->filename = '.';
237 $dir_record->contenthash = $contenthash;
238 $dir_record->filesize = 0;
239
240 $dir_record->timecreated = $now;
241 $dir_record->timemodified = $now;
242 $dir_record->mimetype = null;
243 $dir_record->userid = $userid;
244
245 $dir_record->pathnamehash = $pathnamehash;
246
247 $DB->insert_record('files', $dir_record);
248 $dir_info = $this->get_file_by_hash($pathnamehash);
249
250 if ($filepath !== '/') {
251 //recurse to parent dirs
252 $filepath = trim($filepath, '/');
253 $filepath = explode('/', $filepath);
254 array_pop($filepath);
255 $filepath = implode('/', $filepath);
256 $filepath = ($filepath === '') ? '/' : "/$filepath/";
257 $this->create_directory($contextid, $filearea, $itemid, $filepath, $userid);
258 }
259
260 return $dir_info;
261 }
262
263 /**
264 * Add new local file based on existing local file
265 * @param mixed $file_record object or array describing changes
266 * @param int $fid id of existing local file
267 * @return object stored_file instance
268 */
269 public function create_file_from_localfile($file_record, $fid) {
270 global $DB;
271
272 $file_record = (array)$file_record; // we support arrays too
273 unset($file_record['id']);
274 unset($file_record['filesize']);
275 unset($file_record['contenthash']);
276
277 $now = time();
278
279 if ($newrecord = $DB->get_record('files', array('id'=>$fid))) {
280 throw new file_exception('localfileproblem', 'File does not exist');
281 }
282
283 unset($newrecord->id);
284
285 foreach ($file_record as $key=>$value) {
286 // validate all parameters, we do not want any rubbish stored in database, right?
287 if ($key == 'contextid' and (!is_number($value) or $value < 1)) {
288 throw new file_exception('localfileproblem', 'Invalid contextid');
289 }
290
291 if ($key == 'filearea') {
292 $value = clean_param($value, PARAM_SAFEDIR);
293 if ($value === '') {
294 throw new file_exception('localfileproblem', 'Invalid filearea');
295 }
296 }
297
298 if ($key == 'itemid' and (!is_number($value) or $value < 0)) {
299 throw new file_exception('localfileproblem', 'Invalid itemid');
300 }
301
302
303 if ($key == 'filepath') {
304 $value = clean_param($value, PARAM_PATH);
305 if (strpos($value, '/') !== 0 or strpos($value, '/') !== strlen($value)-1) {
306 // path must start and end with '/'
307 throw new file_exception('localfileproblem', 'Invalid file path');
308 }
309 }
310
311 if ($key == 'filename') {
312 $value = clean_param($value, PARAM_FILE);
313 if ($value === '') {
314 // path must start and end with '/'
315 throw new file_exception('localfileproblem', 'Invalid file name');
316 }
317 }
318
319 $newrecord->$key = $value;
320 }
321
322 $newrecord->pathnamehash = $this->get_pathname_hash($newrecord->contextid, $newrecord->filearea, $newrecord->itemid, $newrecord->filepath, $newrecord->filename);
323
324 try {
325 $newrecord->id = $DB->insert_record('files', $newrecord);
326 } catch (database_exception $e) {
327 $newrecord->id = false;
328 }
329
330 if (!$newrecord->id) {
331 throw new stored_file_creation_exception($newrecord->contextid, $newrecord->filearea, $newrecord->itemid,
332 $newrecord->filepath, $newrecord->filename);
333 }
334
335 $this->create_directory($newrecord->contextid, $newrecord->filearea, $newrecord->itemid, $newrecord->filepath, $newrecord->userid);
336
337 return new stored_file($this, $newrecord);
338 }
339
340 /**
341 * Add new local file
342 * @param mixed $file_record object or array describing file
343 * @param string $path path to file or content of file
344 * @return object stored_file instance
345 */
346 public function create_file_from_pathname($file_record, $pathname) {
347 global $DB;
348
349 $file_record = (object)$file_record; // we support arrays too
350
351 // validate all parameters, we do not want any rubbish stored in database, right?
352 if (!is_number($file_record->contextid) or $file_record->contextid < 1) {
353 throw new file_exception('localfileproblem', 'Invalid contextid');
354 }
355
356 $file_record->filearea = clean_param($file_record->filearea, PARAM_SAFEDIR);
357 if ($file_record->filearea === '') {
358 throw new file_exception('localfileproblem', 'Invalid filearea');
359 }
360
361 if (!is_number($file_record->itemid) or $file_record->itemid < 0) {
362 throw new file_exception('localfileproblem', 'Invalid itemid');
363 }
364
365 $file_record->filepath = clean_param($file_record->filepath, PARAM_PATH);
366 if (strpos($file_record->filepath, '/') !== 0 or strrpos($file_record->filepath, '/') !== strlen($file_record->filepath)-1) {
367 // path must start and end with '/'
368 throw new file_exception('localfileproblem', 'Invalid file path');
369 }
370
371 $file_record->filename = clean_param($file_record->filename, PARAM_FILE);
372 if ($file_record->filename === '') {
373 // path must start and end with '/'
374 throw new file_exception('localfileproblem', 'Invalid file name');
375 }
376
377 $now = time();
378
379 $newrecord = new object();
380
381 $newrecord->contextid = $file_record->contextid;
382 $newrecord->filearea = $file_record->filearea;
383 $newrecord->itemid = $file_record->itemid;
384 $newrecord->filepath = $file_record->filepath;
385 $newrecord->filename = $file_record->filename;
386
387 $newrecord->timecreated = empty($file_record->timecreated) ? $now : $file_record->timecreated;
388 $newrecord->timemodified = empty($file_record->timemodified) ? $now : $file_record->timemodified;
389 $newrecord->mimetype = empty($file_record->mimetype) ? mimeinfo('type', $file_record->filename) : $file_record->mimetype;
390 $newrecord->userid = empty($file_record->userid) ? null : $file_record->userid;
391
392 list($newrecord->contenthash, $newrecord->filesize, $newfile) = $this->add_to_pool_pathname($pathname);
393
394 $newrecord->pathnamehash = $this->get_pathname_hash($newrecord->contextid, $newrecord->filearea, $newrecord->itemid, $newrecord->filepath, $newrecord->filename);
395
396 try {
397 $newrecord->id = $DB->insert_record('files', $newrecord);
398 } catch (database_exception $e) {
399 $newrecord->id = false;
400 }
401
402 if (!$newrecord->id) {
403 if ($newfile) {
404 $this->mark_delete_candidate($newrecord->contenthash);
405 }
406 throw new stored_file_creation_exception($newrecord->contextid, $newrecord->filearea, $newrecord->itemid,
407 $newrecord->filepath, $newrecord->filename);
408 }
409
410 $this->create_directory($newrecord->contextid, $newrecord->filearea, $newrecord->itemid, $newrecord->filepath, $newrecord->userid);
411
412 return new stored_file($this, $newrecord);
413 }
414
415 /**
416 * Add new local file
417 * @param mixed $file_record object or array describing file
418 * @param string $content content of file
419 * @return object stored_file instance
420 */
421 public function create_file_from_string($file_record, $content) {
422 global $DB;
423
424 $file_record = (object)$file_record; // we support arrays too
425
426 // validate all parameters, we do not want any rubbish stored in database, right?
427 if (!is_number($file_record->contextid) or $file_record->contextid < 1) {
428 throw new file_exception('localfileproblem', 'Invalid contextid');
429 }
430
431 $file_record->filearea = clean_param($file_record->filearea, PARAM_SAFEDIR);
432 if ($file_record->filearea === '') {
433 throw new file_exception('localfileproblem', 'Invalid filearea');
434 }
435
436 if (!is_number($file_record->itemid) or $file_record->itemid < 0) {
437 throw new file_exception('localfileproblem', 'Invalid itemid');
438 }
439
440 $file_record->filepath = clean_param($file_record->filepath, PARAM_PATH);
441 if (strpos($file_record->filepath, '/') !== 0 or strrpos($file_record->filepath, '/') !== strlen($file_record->filepath)-1) {
442 // path must start and end with '/'
443 throw new file_exception('localfileproblem', 'Invalid file path');
444 }
445
446 $file_record->filename = clean_param($file_record->filename, PARAM_FILE);
447 if ($file_record->filename === '') {
448 // path must start and end with '/'
449 throw new file_exception('localfileproblem', 'Invalid file name');
450 }
451
452 $now = time();
453
454 $newrecord = new object();
455
456 $newrecord->contextid = $file_record->contextid;
457 $newrecord->filearea = $file_record->filearea;
458 $newrecord->itemid = $file_record->itemid;
459 $newrecord->filepath = $file_record->filepath;
460 $newrecord->filename = $file_record->filename;
461
462 $newrecord->timecreated = empty($file_record->timecreated) ? $now : $file_record->timecreated;
463 $newrecord->timemodified = empty($file_record->timemodified) ? $now : $file_record->timemodified;
464 $newrecord->mimetype = empty($file_record->mimetype) ? mimeinfo('type', $file_record->filename) : $file_record->mimetype;
465 $newrecord->userid = empty($file_record->userid) ? null : $file_record->userid;
466
467 list($newrecord->contenthash, $newrecord->filesize, $newfile) = $this->add_to_pool_string($content);
468
469 $newrecord->pathnamehash = $this->get_pathname_hash($newrecord->contextid, $newrecord->filearea, $newrecord->itemid, $newrecord->filepath, $newrecord->filename);
470
471 try {
472 $newrecord->id = $DB->insert_record('files', $newrecord);
473 } catch (database_exception $e) {
474 $newrecord->id = false;
475 }
476
477 if (!$newrecord->id) {
478 if ($newfile) {
479 $this->mark_delete_candidate($newrecord->contenthash);
480 }
481 throw new stored_file_creation_exception($newrecord->contextid, $newrecord->filearea, $newrecord->itemid,
482 $newrecord->filepath, $newrecord->filename);
483 }
484
485 $this->create_directory($newrecord->contextid, $newrecord->filearea, $newrecord->itemid, $newrecord->filepath, $newrecord->userid);
486
487 return new stored_file($this, $newrecord);
488 }
489
490 /**
491 * Add file content to sha1 pool
492 * @param string $pathname path to file
493 * @param string sha1 hash of content if known (performance only)
494 * @return array(contenthash, filesize, newfile)
495 */
496 public function add_to_pool_pathname($pathname, $contenthash=null) {
497 if (!is_readable($pathname)) {
498 throw new file_exception('localfilecannotread');
499 }
500
501 if (is_null($contenthash)) {
502 $contenthash = sha1_file($pathname);
503 }
504
505 $filesize = filesize($pathname);
506
507 $hashpath = $this->path_from_hash($contenthash);
508 $hashfile = "$hashpath/$contenthash";
509
510 if (file_exists($hashfile)) {
511 if (filesize($hashfile) !== $filesize) {
512 throw new file_pool_content_exception($contenthash);
513 }
514 $newfile = false;
515
516 } else {
517 if (!check_dir_exists($hashpath, true, true)) {
518 throw new file_exception('localfilecannotcreatefiledirs'); // permission trouble
519 }
520 $newfile = true;
521
522 $fs = fopen($pathname, 'rb');
523 $fp = fopen($hashfile, 'wb');
524 while(!feof($fs)) {
525 $buf = fread($fs, 65536);
526 if ($buf === false) {
527 throw new file_exception('localfilecannotread');
528 }
529 fwrite($fp, $buf);
530 }
531 fclose($fp);
532 fclose($fs);
533
534 if (filesize($hashfile) !== $filesize) {
535 @unlink($hashfile);
536 throw new file_pool_content_exception($contenthash);
537 }
538 }
539
540
541 return array($contenthash, $filesize, $newfile);
542 }
543
544 /**
545 * Add string content to sha1 pool
546 * @param string $content file content - binary string
547 * @return array(contenthash, filesize, newfile)
548 */
549 public function add_to_pool_string($content) {
550 $contenthash = sha1($content);
551 $filesize = strlen($content); // binary length
552
553 $hashpath = $this->path_from_hash($contenthash);
554 $hashfile = "$hashpath/$contenthash";
555
556
557 if (file_exists($hashfile)) {
558 if (filesize($hashfile) !== $filesize) {
559 throw new file_pool_content_exception($contenthash);
560 }
561 $newfile = false;
562
563 } else {
564 if (!check_dir_exists($hashpath, true, true)) {
565 throw new file_exception('localfilecannotcreatefiledirs'); // permission trouble
566 }
567 $newfile = true;
568
569 $fp = fopen($hashfile, 'wb');
570
571 fwrite($fp, $content);
572 fclose($fp);
573
574 if (filesize($hashfile) !== $filesize) {
575 @unlink($hashfile);
576 throw new file_pool_content_exception($contenthash);
577 }
578 }
579
580 return array($contenthash, $filesize, $newfile);
581 }
582
583 /**
584 * Return path to file with given hash
585 *
586 * DO NOT USE - should be protected, but protected is dumb in PHP
587 *
588 * @param string $contenthash
589 * @return string expected file location
590 */
591 public function path_from_hash($contenthash) {
592 $l1 = $contenthash[0].$contenthash[1];
593 $l2 = $contenthash[2].$contenthash[3];
594 $l3 = $contenthash[4].$contenthash[5];
595 return "$this->filedir/$l1/$l2/$l3";
596 }
597
598 /**
599 * Marks pool file as candidate for deleting
600 * @param string $contenthash
601 */
602 public function mark_delete_candidate($contenthash) {
603 global $DB;
604
605 if ($DB->record_exists('files_cleanup', array('contenthash'=>$contenthash))) {
606 return;
607 }
608 $rec = new object();
609 $rec->contenthash = $contenthash;
610 $DB->insert_record('files_cleanup', $rec);
611 }
612
613 /**
614 * Cron cleanup job.
615 */
616 public function cron() {
617 global $DB;
618
619 //TODO: there is a small chance that reused files might be deleted
620 // if this function takes too long we should add some table locking here
621
622 $sql = "SELECT 1 AS id, fc.contenthash
623 FROM {files_cleanup} fc
624 LEFT JOIN {files} f ON f.contenthash = fc.contenthash
625 WHERE f.id IS NULL";
626 while ($hash = $DB->get_record_sql($sql, null, true)) {
627 $file = $this->path_from_hash($hash->contenthash).'/'.$hash->contenthash;
628 @unlink($file);
629 $DB->delete_records('files_cleanup', array('contenthash'=>$hash->contenthash));
630 }
631 }
632}