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