fbf101757ab1631aa09ca51d30c87e0628d2bcf0
[lhc/web/wiklou.git] / includes / filerepo / backend / FSFileBackend.php
1 <?php
2 /**
3 * @file
4 * @ingroup FileBackend
5 * @author Aaron Schulz
6 */
7
8 /**
9 * Class for a file system (FS) based file backend.
10 *
11 * All "containers" each map to a directory under the backend's base directory.
12 * For backwards-compatibility, some container paths can be set to custom paths.
13 * The wiki ID will not be used in any custom paths, so this should be avoided.
14 *
15 * Having directories with thousands of files will diminish performance.
16 * Sharding can be accomplished by using FileRepo-style hash paths.
17 *
18 * Status messages should avoid mentioning the internal FS paths.
19 * Likewise, error suppression should be used to avoid path disclosure.
20 *
21 * @ingroup FileBackend
22 */
23 class FSFileBackend extends FileBackend {
24 protected $basePath; // string; directory holding the container directories
25 /** @var Array Map of container names to root paths */
26 protected $containerPaths = array(); // for custom container paths
27 protected $fileMode; // integer; file permission mode
28
29 /**
30 * @see FileBackend::__construct()
31 * Additional $config params include:
32 * basePath : File system directory that holds containers.
33 * containerPaths : Map of container names to custom file system directories.
34 * This should only be used for backwards-compatibility.
35 * fileMode : Octal UNIX file permissions to use on files stored.
36 */
37 public function __construct( array $config ) {
38 parent::__construct( $config );
39 if ( isset( $config['basePath'] ) ) {
40 if ( substr( $this->basePath, -1 ) === '/' ) {
41 $this->basePath = substr( $this->basePath, 0, -1 ); // remove trailing slash
42 }
43 } else {
44 $this->basePath = null; // none; containers must have explicit paths
45 }
46 $this->containerPaths = (array)$config['containerPaths'];
47 foreach ( $this->containerPaths as &$path ) {
48 if ( substr( $path, -1 ) === '/' ) {
49 $path = substr( $path, 0, -1 ); // remove trailing slash
50 }
51 }
52 $this->fileMode = isset( $config['fileMode'] )
53 ? $config['fileMode']
54 : 0644;
55 }
56
57 /**
58 * @see FileBackend::resolveContainerPath()
59 */
60 protected function resolveContainerPath( $container, $relStoragePath ) {
61 if ( isset( $this->containerPaths[$container] ) || isset( $this->basePath ) ) {
62 return $relStoragePath; // container has a root directory
63 }
64 return null;
65 }
66
67 /**
68 * Given the short (unresolved) and full (resolved) name of
69 * a container, return the file system path of the container.
70 *
71 * @param $shortCont string
72 * @param $fullCont string
73 * @return string|null
74 */
75 protected function containerFSRoot( $shortCont, $fullCont ) {
76 if ( isset( $this->containerPaths[$shortCont] ) ) {
77 return $this->containerPaths[$shortCont];
78 } elseif ( isset( $this->basePath ) ) {
79 return "{$this->basePath}/{$fullCont}";
80 }
81 return null; // no container base path defined
82 }
83
84 /**
85 * Get the absolute file system path for a storage path
86 *
87 * @param $storagePath string Storage path
88 * @return string|null
89 */
90 protected function resolveToFSPath( $storagePath ) {
91 list( $fullCont, $relPath ) = $this->resolveStoragePathReal( $storagePath );
92 if ( $relPath === null ) {
93 return null; // invalid
94 }
95 list( $b, $shortCont, $r ) = FileBackend::splitStoragePath( $storagePath );
96 $fsPath = $this->containerFSRoot( $shortCont, $fullCont ); // must be valid
97 if ( $relPath != '' ) {
98 $fsPath .= "/{$relPath}";
99 }
100 return $fsPath;
101 }
102
103 /**
104 * @see FileBackend::doStoreInternal()
105 */
106 protected function doStoreInternal( array $params ) {
107 $status = Status::newGood();
108
109 $dest = $this->resolveToFSPath( $params['dst'] );
110 if ( $dest === null ) {
111 $status->fatal( 'backend-fail-invalidpath', $params['dst'] );
112 return $status;
113 }
114
115 if ( file_exists( $dest ) ) {
116 if ( !empty( $params['overwriteDest'] ) ) {
117 wfSuppressWarnings();
118 $ok = unlink( $dest );
119 wfRestoreWarnings();
120 if ( !$ok ) {
121 $status->fatal( 'backend-fail-delete', $params['dst'] );
122 return $status;
123 }
124 } else {
125 $status->fatal( 'backend-fail-alreadyexists', $params['dst'] );
126 return $status;
127 }
128 } else {
129 if ( !wfMkdirParents( dirname( $dest ) ) ) {
130 $status->fatal( 'directorycreateerror', $params['dst'] );
131 return $status;
132 }
133 }
134
135 wfSuppressWarnings();
136 $ok = copy( $params['src'], $dest );
137 wfRestoreWarnings();
138 if ( !$ok ) {
139 $status->fatal( 'backend-fail-copy', $params['src'], $params['dst'] );
140 return $status;
141 }
142
143 $this->chmod( $dest );
144
145 return $status;
146 }
147
148 /**
149 * @see FileBackend::doCopyInternal()
150 */
151 protected function doCopyInternal( array $params ) {
152 $status = Status::newGood();
153
154 $source = $this->resolveToFSPath( $params['src'] );
155 if ( $source === null ) {
156 $status->fatal( 'backend-fail-invalidpath', $params['src'] );
157 return $status;
158 }
159
160 $dest = $this->resolveToFSPath( $params['dst'] );
161 if ( $dest === null ) {
162 $status->fatal( 'backend-fail-invalidpath', $params['dst'] );
163 return $status;
164 }
165
166 if ( file_exists( $dest ) ) {
167 if ( !empty( $params['overwriteDest'] ) ) {
168 wfSuppressWarnings();
169 $ok = unlink( $dest );
170 wfRestoreWarnings();
171 if ( !$ok ) {
172 $status->fatal( 'backend-fail-delete', $params['dst'] );
173 return $status;
174 }
175 } else {
176 $status->fatal( 'backend-fail-alreadyexists', $params['dst'] );
177 return $status;
178 }
179 } else {
180 if ( !wfMkdirParents( dirname( $dest ) ) ) {
181 $status->fatal( 'directorycreateerror', $params['dst'] );
182 return $status;
183 }
184 }
185
186 wfSuppressWarnings();
187 $ok = copy( $source, $dest );
188 wfRestoreWarnings();
189 if ( !$ok ) {
190 $status->fatal( 'backend-fail-copy', $params['src'], $params['dst'] );
191 return $status;
192 }
193
194 $this->chmod( $dest );
195
196 return $status;
197 }
198
199 /**
200 * @see FileBackend::doMoveInternal()
201 */
202 protected function doMoveInternal( array $params ) {
203 $status = Status::newGood();
204
205 $source = $this->resolveToFSPath( $params['src'] );
206 if ( $source === null ) {
207 $status->fatal( 'backend-fail-invalidpath', $params['src'] );
208 return $status;
209 }
210
211 $dest = $this->resolveToFSPath( $params['dst'] );
212 if ( $dest === null ) {
213 $status->fatal( 'backend-fail-invalidpath', $params['dst'] );
214 return $status;
215 }
216
217 if ( file_exists( $dest ) ) {
218 if ( !empty( $params['overwriteDest'] ) ) {
219 // Windows does not support moving over existing files
220 if ( wfIsWindows() ) {
221 wfSuppressWarnings();
222 $ok = unlink( $dest );
223 wfRestoreWarnings();
224 if ( !$ok ) {
225 $status->fatal( 'backend-fail-delete', $params['dst'] );
226 return $status;
227 }
228 }
229 } else {
230 $status->fatal( 'backend-fail-alreadyexists', $params['dst'] );
231 return $status;
232 }
233 } else {
234 if ( !wfMkdirParents( dirname( $dest ) ) ) {
235 $status->fatal( 'directorycreateerror', $params['dst'] );
236 return $status;
237 }
238 }
239
240 wfSuppressWarnings();
241 $ok = rename( $source, $dest );
242 clearstatcache(); // file no longer at source
243 wfRestoreWarnings();
244 if ( !$ok ) {
245 $status->fatal( 'backend-fail-move', $params['src'], $params['dst'] );
246 return $status;
247 }
248
249 return $status;
250 }
251
252 /**
253 * @see FileBackend::doDeleteInternal()
254 */
255 protected function doDeleteInternal( array $params ) {
256 $status = Status::newGood();
257
258 $source = $this->resolveToFSPath( $params['src'] );
259 if ( $source === null ) {
260 $status->fatal( 'backend-fail-invalidpath', $params['src'] );
261 return $status;
262 }
263
264 if ( !is_file( $source ) ) {
265 if ( empty( $params['ignoreMissingSource'] ) ) {
266 $status->fatal( 'backend-fail-delete', $params['src'] );
267 }
268 return $status; // do nothing; either OK or bad status
269 }
270
271 wfSuppressWarnings();
272 $ok = unlink( $source );
273 wfRestoreWarnings();
274 if ( !$ok ) {
275 $status->fatal( 'backend-fail-delete', $params['src'] );
276 return $status;
277 }
278
279 return $status;
280 }
281
282 /**
283 * @see FileBackend::doCreateInternal()
284 */
285 protected function doCreateInternal( array $params ) {
286 $status = Status::newGood();
287
288 $dest = $this->resolveToFSPath( $params['dst'] );
289 if ( $dest === null ) {
290 $status->fatal( 'backend-fail-invalidpath', $params['dst'] );
291 return $status;
292 }
293
294 if ( file_exists( $dest ) ) {
295 if ( !empty( $params['overwriteDest'] ) ) {
296 wfSuppressWarnings();
297 $ok = unlink( $dest );
298 wfRestoreWarnings();
299 if ( !$ok ) {
300 $status->fatal( 'backend-fail-delete', $params['dst'] );
301 return $status;
302 }
303 } else {
304 $status->fatal( 'backend-fail-alreadyexists', $params['dst'] );
305 return $status;
306 }
307 } else {
308 if ( !wfMkdirParents( dirname( $dest ) ) ) {
309 $status->fatal( 'directorycreateerror', $params['dst'] );
310 return $status;
311 }
312 }
313
314 wfSuppressWarnings();
315 $ok = file_put_contents( $dest, $params['content'] );
316 wfRestoreWarnings();
317 if ( !$ok ) {
318 $status->fatal( 'backend-fail-create', $params['dst'] );
319 return $status;
320 }
321
322 $this->chmod( $dest );
323
324 return $status;
325 }
326
327 /**
328 * @see FileBackend::doPrepareInternal()
329 */
330 protected function doPrepareInternal( $fullCont, $dirRel, array $params ) {
331 $status = Status::newGood();
332 list( $b, $shortCont, $r ) = FileBackend::splitStoragePath( $params['dir'] );
333 $contRoot = $this->containerFSRoot( $shortCont, $fullCont ); // must be valid
334 $dir = ( $dirRel != '' ) ? "{$contRoot}/{$dirRel}" : $contRoot;
335 if ( !wfMkdirParents( $dir ) ) {
336 $status->fatal( 'directorycreateerror', $params['dir'] );
337 } elseif ( !is_writable( $dir ) ) {
338 $status->fatal( 'directoryreadonlyerror', $params['dir'] );
339 } elseif ( !is_readable( $dir ) ) {
340 $status->fatal( 'directorynotreadableerror', $params['dir'] );
341 }
342 return $status;
343 }
344
345 /**
346 * @see FileBackend::doSecureInternal()
347 */
348 protected function doSecureInternal( $fullCont, $dirRel, array $params ) {
349 $status = Status::newGood();
350 list( $b, $shortCont, $r ) = FileBackend::splitStoragePath( $params['dir'] );
351 $contRoot = $this->containerFSRoot( $shortCont, $fullCont ); // must be valid
352 $dir = ( $dirRel != '' ) ? "{$contRoot}/{$dirRel}" : $contRoot;
353 if ( !wfMkdirParents( $dir ) ) {
354 $status->fatal( 'directorycreateerror', $params['dir'] );
355 return $status;
356 }
357 // Seed new directories with a blank index.html, to prevent crawling...
358 if ( !empty( $params['noListing'] ) && !file_exists( "{$dir}/index.html" ) ) {
359 wfSuppressWarnings();
360 $ok = file_put_contents( "{$dir}/index.html", '' );
361 wfRestoreWarnings();
362 if ( !$ok ) {
363 $status->fatal( 'backend-fail-create', $params['dir'] . '/index.html' );
364 return $status;
365 }
366 }
367 // Add a .htaccess file to the root of the container...
368 if ( !empty( $params['noAccess'] ) ) {
369 if ( !file_exists( "{$contRoot}/.htaccess" ) ) {
370 wfSuppressWarnings();
371 $ok = file_put_contents( "{$dirRoot}/.htaccess", "Deny from all\n" );
372 wfRestoreWarnings();
373 if ( !$ok ) {
374 $storeDir = "mwstore://{$this->name}/{$shortCont}";
375 $status->fatal( 'backend-fail-create', "{$storeDir}/.htaccess" );
376 return $status;
377 }
378 }
379 }
380 return $status;
381 }
382
383 /**
384 * @see FileBackend::doCleanInternal()
385 */
386 protected function doCleanInternal( $fullCont, $dirRel, array $params ) {
387 $status = Status::newGood();
388 list( $b, $shortCont, $r ) = FileBackend::splitStoragePath( $params['dir'] );
389 $contRoot = $this->containerFSRoot( $shortCont, $fullCont ); // must be valid
390 $dir = ( $dirRel != '' ) ? "{$contRoot}/{$dirRel}" : $contRoot;
391 wfSuppressWarnings();
392 if ( is_dir( $dir ) ) {
393 rmdir( $dir ); // remove directory if empty
394 }
395 wfRestoreWarnings();
396 return $status;
397 }
398
399 /**
400 * @see FileBackend::doFileExists()
401 */
402 protected function doGetFileStat( array $params ) {
403 $source = $this->resolveToFSPath( $params['src'] );
404 if ( $source === null ) {
405 return false; // invalid storage path
406 }
407
408 $this->trapWarnings();
409 $stat = is_file( $source ) ? stat( $source ) : false; // regular files only
410 $hadError = $this->untrapWarnings();
411
412 if ( $stat ) {
413 return array(
414 'mtime' => wfTimestamp( TS_MW, $stat['mtime'] ),
415 'size' => $stat['size']
416 );
417 } elseif ( !$hadError ) {
418 return false; // file does not exist
419 } else {
420 return null; // failure
421 }
422 }
423
424 /**
425 * @see FileBackend::getFileListInternal()
426 */
427 public function getFileListInternal( $fullCont, $dirRel, array $params ) {
428 list( $b, $shortCont, $r ) = FileBackend::splitStoragePath( $params['dir'] );
429 $contRoot = $this->containerFSRoot( $shortCont, $fullCont ); // must be valid
430 $dir = ( $dirRel != '' ) ? "{$contRoot}/{$dirRel}" : $contRoot;
431 wfSuppressWarnings();
432 $exists = is_dir( $dir );
433 wfRestoreWarnings();
434 if ( !$exists ) {
435 return array(); // nothing under this dir
436 }
437 wfSuppressWarnings();
438 $readable = is_readable( $dir );
439 wfRestoreWarnings();
440 if ( !$readable ) {
441 return null; // bad permissions?
442 }
443 return new FSFileIterator( $dir );
444 }
445
446 /**
447 * @see FileBackend::getLocalReference()
448 */
449 public function getLocalReference( array $params ) {
450 $source = $this->resolveToFSPath( $params['src'] );
451 if ( $source === null ) {
452 return null;
453 }
454 return new FSFile( $source );
455 }
456
457 /**
458 * @see FileBackend::getLocalCopy()
459 */
460 public function getLocalCopy( array $params ) {
461 $source = $this->resolveToFSPath( $params['src'] );
462 if ( $source === null ) {
463 return null;
464 }
465
466 // Create a new temporary file with the same extension...
467 $ext = FileBackend::extensionFromPath( $params['src'] );
468 $tmpFile = TempFSFile::factory( wfBaseName( $source ) . '_', $ext );
469 if ( !$tmpFile ) {
470 return null;
471 }
472 $tmpPath = $tmpFile->getPath();
473
474 // Copy the source file over the temp file
475 wfSuppressWarnings();
476 $ok = copy( $source, $tmpPath );
477 wfRestoreWarnings();
478 if ( !$ok ) {
479 return null;
480 }
481
482 $this->chmod( $tmpPath );
483
484 return $tmpFile;
485 }
486
487 /**
488 * Chmod a file, suppressing the warnings
489 *
490 * @param $path string Absolute file system path
491 * @return bool Success
492 */
493 protected function chmod( $path ) {
494 wfSuppressWarnings();
495 $ok = chmod( $path, $this->fileMode );
496 wfRestoreWarnings();
497
498 return $ok;
499 }
500
501 /**
502 * Suppress E_WARNING errors and track whether any happen
503 *
504 * @return void
505 */
506 protected function trapWarnings() {
507 $this->hadWarningErrors[] = false; // push to stack
508 set_error_handler( array( $this, 'handleWarning' ), E_WARNING );
509 }
510
511 /**
512 * Unsuppress E_WARNING errors and return true if any happened
513 *
514 * @return bool
515 */
516 protected function untrapWarnings() {
517 restore_error_handler(); // restore previous handler
518 return array_pop( $this->hadWarningErrors ); // pop from stack
519 }
520
521 private function handleWarning() {
522 $this->hadWarningErrors[count( $this->hadWarningErrors ) - 1] = true;
523 return true; // suppress from PHP handler
524 }
525 }
526
527 /**
528 * Wrapper around RecursiveDirectoryIterator that catches
529 * exception or does any custom behavoir that we may want.
530 *
531 * @ingroup FileBackend
532 */
533 class FSFileIterator implements Iterator {
534 /** @var RecursiveIteratorIterator */
535 protected $iter;
536 protected $suffixStart; // integer
537
538 /**
539 * Get an FSFileIterator from a file system directory
540 *
541 * @param $dir string
542 */
543 public function __construct( $dir ) {
544 $dir = realpath( $dir ); // normalize
545 $this->suffixStart = strlen( $dir ) + 1; // size of "path/to/dir/"
546 try {
547 $flags = FilesystemIterator::CURRENT_AS_FILEINFO | FilesystemIterator::SKIP_DOTS;
548 $this->iter = new RecursiveIteratorIterator(
549 new RecursiveDirectoryIterator( $dir, $flags ) );
550 } catch ( UnexpectedValueException $e ) {
551 $this->iter = null; // bad permissions? deleted?
552 }
553 }
554
555 public function current() {
556 // Return only the relative path and normalize slashes to FileBackend-style
557 // Make sure to use the realpath since the suffix is based upon that
558 return str_replace( '\\', '/',
559 substr( realpath( $this->iter->current() ), $this->suffixStart ) );
560 }
561
562 public function key() {
563 return $this->iter->key();
564 }
565
566 public function next() {
567 try {
568 $this->iter->next();
569 } catch ( UnexpectedValueException $e ) {
570 $this->iter = null;
571 }
572 }
573
574 public function rewind() {
575 try {
576 $this->iter->rewind();
577 } catch ( UnexpectedValueException $e ) {
578 $this->iter = null;
579 }
580 }
581
582 public function valid() {
583 return $this->iter && $this->iter->valid();
584 }
585 }