Fixed some errant double-slashes appearing in file paths. Added unit tests for the...
[lhc/web/wiklou.git] / includes / filerepo / FSRepo.php
1 <?php
2
3 /**
4 * A repository for files accessible via the local filesystem. Does not support
5 * database access or registration.
6 *
7 * TODO: split off abstract base FileRepo
8 */
9
10 class FSRepo {
11 const DELETE_SOURCE = 1;
12
13 var $directory, $url, $hashLevels, $thumbScriptUrl, $transformVia404;
14 var $descBaseUrl, $scriptDirUrl, $articleUrl, $fetchDescription, $initialCapital;
15 var $fileFactory = array( 'UnregisteredLocalFile', 'newFromTitle' );
16 var $oldFileFactory = false;
17
18 function __construct( $info ) {
19 // Required settings
20 $this->name = $info['name'];
21 $this->directory = $info['directory'];
22 $this->url = $info['url'];
23 $this->hashLevels = $info['hashLevels'];
24 $this->transformVia404 = !empty( $info['transformVia404'] );
25
26 // Optional settings
27 $this->initialCapital = true; // by default
28 foreach ( array( 'descBaseUrl', 'scriptDirUrl', 'articleUrl', 'fetchDescription',
29 'thumbScriptUrl', 'initialCapital' ) as $var )
30 {
31 if ( isset( $info[$var] ) ) {
32 $this->$var = $info[$var];
33 }
34 }
35 }
36
37 /**
38 * Create a new File object from the local repository
39 * @param mixed $title Title object or string
40 * @param mixed $time Time at which the image is supposed to have existed.
41 * If this is specified, the returned object will be an
42 * instance of the repository's old file class instead of
43 * a current file. Repositories not supporting version
44 * control should return false if this parameter is set.
45 */
46 function newFile( $title, $time = false ) {
47 if ( !($title instanceof Title) ) {
48 $title = Title::makeTitleSafe( NS_IMAGE, $title );
49 if ( !is_object( $title ) ) {
50 return null;
51 }
52 }
53 if ( $time ) {
54 if ( $this->oldFileFactory ) {
55 return call_user_func( $this->oldFileFactory, $title, $this, $time );
56 } else {
57 return false;
58 }
59 } else {
60 return call_user_func( $this->fileFactory, $title, $this );
61 }
62 }
63
64 /**
65 * Find an instance of the named file that existed at the specified time
66 * Returns false if the file did not exist. Repositories not supporting
67 * version control should return false if the time is specified.
68 *
69 * @param mixed $time 14-character timestamp, or false for the current version
70 */
71 function findFile( $title, $time = false ) {
72 # First try the current version of the file to see if it precedes the timestamp
73 $img = $this->newFile( $title );
74 if ( !$img ) {
75 return false;
76 }
77 if ( $img->exists() && ( !$time || $img->getTimestamp() <= $time ) ) {
78 return $img;
79 }
80 # Now try an old version of the file
81 $img = $this->newFile( $title, $time );
82 if ( $img->exists() ) {
83 return $img;
84 }
85 }
86
87 /**
88 * Get the public root directory of the repository.
89 */
90 function getRootDirectory() {
91 return $this->directory;
92 }
93
94 /**
95 * Get the public root URL of the repository
96 */
97 function getRootUrl() {
98 return $this->url;
99 }
100
101 /**
102 * Returns true if the repository uses a multi-level directory structure
103 */
104 function isHashed() {
105 return (bool)$this->hashLevels;
106 }
107
108 /**
109 * Get the URL of thumb.php
110 */
111 function getThumbScriptUrl() {
112 return $this->thumbScriptUrl;
113 }
114
115 /**
116 * Returns true if the repository can transform files via a 404 handler
117 */
118 function canTransformVia404() {
119 return $this->transformVia404;
120 }
121
122 /**
123 * Get the local directory corresponding to one of the three basic zones
124 */
125 function getZonePath( $zone ) {
126 switch ( $zone ) {
127 case 'public':
128 return $this->directory;
129 case 'temp':
130 return "{$this->directory}/temp";
131 case 'deleted':
132 return $GLOBALS['wgFileStore']['deleted']['directory'];
133 default:
134 return false;
135 }
136 }
137
138 /**
139 * Get the URL corresponding to one of the three basic zones
140 */
141 function getZoneUrl( $zone ) {
142 switch ( $zone ) {
143 case 'public':
144 return $this->url;
145 case 'temp':
146 return "{$this->url}/temp";
147 case 'deleted':
148 return $GLOBALS['wgFileStore']['deleted']['url'];
149 default:
150 return false;
151 }
152 }
153
154 /**
155 * Get a URL referring to this repository, with the private mwrepo protocol.
156 */
157 function getVirtualUrl( $suffix = false ) {
158 $path = 'mwrepo://';
159 if ( $suffix !== false ) {
160 $path .= '/' . $suffix;
161 }
162 return $path;
163 }
164
165 /**
166 * Get the local path corresponding to a virtual URL
167 */
168 function resolveVirtualUrl( $url ) {
169 if ( substr( $url, 0, 9 ) != 'mwrepo://' ) {
170 throw new MWException( __METHOD__.': unknown protoocl' );
171 }
172
173 $bits = explode( '/', substr( $url, 9 ), 3 );
174 if ( count( $bits ) != 3 ) {
175 throw new MWException( __METHOD__.": invalid mwrepo URL: $url" );
176 }
177 list( $host, $zone, $rel ) = $bits;
178 if ( $host !== '' ) {
179 throw new MWException( __METHOD__.": fetching from a foreign repo is not supported" );
180 }
181 $base = $this->getZonePath( $zone );
182 if ( !$base ) {
183 throw new MWException( __METHOD__.": invalid zone: $zone" );
184 }
185 return $base . '/' . urldecode( $rel );
186 }
187
188 /**
189 * Store a file to a given destination.
190 */
191 function store( $srcPath, $dstZone, $dstRel, $flags = 0 ) {
192 $root = $this->getZonePath( $dstZone );
193 if ( !$root ) {
194 throw new MWException( "Invalid zone: $dstZone" );
195 }
196 $dstPath = "$root/$dstRel";
197
198 if ( !is_dir( dirname( $dstPath ) ) ) {
199 wfMkdirParents( dirname( $dstPath ) );
200 }
201
202 if ( substr( $srcPath, 0, 9 ) == 'mwrepo://' ) {
203 $srcPath = $this->resolveVirtualUrl( $srcPath );
204 }
205
206 if ( $flags & self::DELETE_SOURCE ) {
207 if ( !rename( $srcPath, $dstPath ) ) {
208 return new WikiErrorMsg( 'filerenameerror', wfEscapeWikiText( $srcPath ),
209 wfEscapeWikiText( $dstPath ) );
210 }
211 } else {
212 if ( !copy( $srcPath, $dstPath ) ) {
213 return new WikiErrorMsg( 'filecopyerror', wfEscapeWikiText( $srcPath ),
214 wfEscapeWikiText( $dstPath ) );
215 }
216 }
217 chmod( $dstPath, 0644 );
218 return true;
219 }
220
221 /**
222 * Pick a random name in the temp zone and store a file to it.
223 * Returns the URL, or a WikiError on failure.
224 * @param string $originalName The base name of the file as specified
225 * by the user. The file extension will be maintained.
226 * @param string $srcPath The current location of the file.
227 */
228 function storeTemp( $originalName, $srcPath ) {
229 $dstRel = $this->getHashPath( $originalName ) .
230 gmdate( "YmdHis" ) . '!' . $originalName;
231 $result = $this->store( $srcPath, 'temp', $dstRel );
232 if ( WikiError::isError( $result ) ) {
233 return $result;
234 } else {
235 return $this->getVirtualUrl( "temp/$dstRel" );
236 }
237 }
238
239 /**
240 * Remove a temporary file or mark it for garbage collection
241 * @param string $virtualUrl The virtual URL returned by storeTemp
242 * @return boolean True on success, false on failure
243 */
244 function freeTemp( $virtualUrl ) {
245 $temp = 'mwrepo:///temp';
246 if ( substr( $virtualUrl, 0, strlen( $temp ) ) != $temp ) {
247 wfDebug( __METHOD__.": Invalid virtual URL\n" );
248 return false;
249 }
250 $path = $this->resolveVirtualUrl( $virtualUrl );
251 wfSuppressWarnings();
252 $success = unlink( $path );
253 wfRestoreWarnings();
254 return $success;
255 }
256
257
258 /**
259 * Copy or move a file either from the local filesystem or from an mwrepo://
260 * virtual URL, into this repository at the specified destination location.
261 *
262 * @param string $srcPath The source path or URL
263 * @param string $dstPath The destination relative path
264 * @param string $archivePath The relative path where the existing file is to
265 * be archived, if there is one.
266 * @param integer $flags Bitfield, may be FSRepo::DELETE_SOURCE to indicate
267 * that the source file should be deleted if possible
268 */
269 function publish( $srcPath, $dstPath, $archivePath, $flags = 0 ) {
270 if ( substr( $srcPath, 0, 9 ) == 'mwrepo://' ) {
271 $srcPath = $this->resolveVirtualUrl( $srcPath );
272 }
273 $dstDir = dirname( $dstPath );
274 if ( !is_dir( $dstDir ) ) wfMkdirParents( $dstDir );
275
276 if( is_file( $dstPath ) ) {
277 $archiveDir = dirname( $archivePath );
278 if ( !is_dir( $archiveDir ) ) wfMkdirParents( $archiveDir );
279 wfSuppressWarnings();
280 $success = rename( $dstPath, $archivePath );
281 wfRestoreWarnings();
282
283 if( ! $success ) {
284 return new WikiErrorMsg( 'filerenameerror', wfEscapeWikiText( $dstPath ),
285 wfEscapeWikiText( $archivePath ) );
286 }
287 else wfDebug(__METHOD__.": moved file $dstPath to $archivePath\n");
288 $status = 'archived';
289 }
290 else {
291 $status = 'new';
292 }
293
294 $error = false;
295 wfSuppressWarnings();
296 if ( $flags & self::DELETE_SOURCE ) {
297 if ( !rename( $srcPath, $dstPath ) ) {
298 $error = new WikiErrorMsg( 'filerenameerror', wfEscapeWikiText( $srcPath ),
299 wfEscapeWikiText( $dstPath ) );
300 }
301 } else {
302 if ( !copy( $srcPath, $dstPath ) ) {
303 $error = new WikiErrorMsg( 'filerenameerror', wfEscapeWikiText( $srcPath ),
304 wfEscapeWikiText( $dstPath ) );
305 }
306 }
307 wfRestoreWarnings();
308
309 if( $error ) {
310 return $error;
311 } else {
312 wfDebug(__METHOD__.": wrote tempfile $srcPath to $dstPath\n");
313 }
314
315 chmod( $dstPath, 0644 );
316 return $status;
317 }
318
319 /**
320 * Get a relative path including trailing slash, e.g. f/fa/
321 * If the repo is not hashed, returns an empty string
322 */
323 function getHashPath( $name ) {
324 if ( $this->isHashed() ) {
325 $hash = md5( $name );
326 $path = '';
327 for ( $i = 1; $i <= $this->hashLevels; $i++ ) {
328 $path .= substr( $hash, 0, $i ) . '/';
329 }
330 return $path;
331 } else {
332 return '';
333 }
334 }
335
336 /**
337 * Get the name of this repository, as specified by $info['name]' to the constructor
338 */
339 function getName() {
340 return $this->name;
341 }
342
343 /**
344 * Get the file description page base URL, or false if there isn't one.
345 * @private
346 */
347 function getDescBaseUrl() {
348 if ( is_null( $this->descBaseUrl ) ) {
349 if ( !is_null( $this->articleUrl ) ) {
350 $this->descBaseUrl = str_replace( '$1',
351 urlencode( Namespace::getCanonicalName( NS_IMAGE ) ) . ':', $this->articleUrl );
352 } elseif ( !is_null( $this->scriptDirUrl ) ) {
353 $this->descBaseUrl = $this->scriptDirUrl . '/index.php?title=' .
354 urlencode( Namespace::getCanonicalName( NS_IMAGE ) ) . ':';
355 } else {
356 $this->descBaseUrl = false;
357 }
358 }
359 return $this->descBaseUrl;
360 }
361
362 /**
363 * Get the URL of an image description page. May return false if it is
364 * unknown or not applicable. In general this should only be called by the
365 * File class, since it may return invalid results for certain kinds of
366 * repositories. Use File::getDescriptionUrl() in user code.
367 *
368 * In particular, it uses the article paths as specified to the repository
369 * constructor, whereas local repositories use the local Title functions.
370 */
371 function getDescriptionUrl( $name ) {
372 $base = $this->getDescBaseUrl();
373 if ( $base ) {
374 return $base . wfUrlencode( $name );
375 } else {
376 return false;
377 }
378 }
379
380 /**
381 * Get the URL of the content-only fragment of the description page. For
382 * MediaWiki this means action=render. This should only be called by the
383 * repository's file class, since it may return invalid results. User code
384 * should use File::getDescriptionText().
385 */
386 function getDescriptionRenderUrl( $name ) {
387 if ( isset( $this->scriptDirUrl ) ) {
388 return $this->scriptDirUrl . '/index.php?title=' .
389 wfUrlencode( Namespace::getCanonicalName( NS_IMAGE ) . ':' . $name ) .
390 '&action=render';
391 } else {
392 $descBase = $this->getDescBaseUrl();
393 if ( $descBase ) {
394 return wfAppendQuery( $descBase . wfUrlencode( $name ), 'action=render' );
395 } else {
396 return false;
397 }
398 }
399 }
400
401 /**
402 * Call a callback function for every file in the repository.
403 * Uses the filesystem even in child classes.
404 */
405 function enumFilesInFS( $callback ) {
406 $numDirs = 1 << ( $this->hashLevels * 4 );
407 for ( $flatIndex = 0; $flatIndex < $numDirs; $flatIndex++ ) {
408 $hexString = sprintf( "%0{$this->hashLevels}x", $flatIndex );
409 $path = $this->directory;
410 for ( $hexPos = 0; $hexPos < $this->hashLevels; $hexPos++ ) {
411 $path .= '/' . substr( $hexString, 0, $hexPos + 1 );
412 }
413 if ( !file_exists( $path ) || !is_dir( $path ) ) {
414 continue;
415 }
416 $dir = opendir( $path );
417 while ( false !== ( $name = readdir( $dir ) ) ) {
418 call_user_func( $callback, $path . '/' . $name );
419 }
420 }
421 }
422
423 /**
424 * Call a callaback function for every file in the repository
425 * May use either the database or the filesystem
426 */
427 function enumFiles( $callback ) {
428 $this->enumFilesInFS( $callback );
429 }
430
431 /**
432 * Get the name of an image from its title object
433 */
434 function getNameFromTitle( $title ) {
435 global $wgCapitalLinks;
436 if ( $this->initialCapital != $wgCapitalLinks ) {
437 global $wgContLang;
438 $name = $title->getUserCaseDBKey();
439 if ( $this->initialCapital ) {
440 $name = $wgContLang->ucfirst( $name );
441 }
442 } else {
443 $name = $title->getDBkey();
444 }
445 return $name;
446 }
447 }
448
449 ?>