core changes for UploadWizard (merged from r73549 to HEAD in branches/uploadwizard...
authorNeil Kandalgaonkar <neilk@users.mediawiki.org>
Wed, 3 Nov 2010 04:32:41 +0000 (04:32 +0000)
committerNeil Kandalgaonkar <neilk@users.mediawiki.org>
Wed, 3 Nov 2010 04:32:41 +0000 (04:32 +0000)
13 files changed:
includes/AutoLoader.php
includes/SpecialPage.php
includes/api/ApiQueryImageInfo.php
includes/api/ApiUpload.php
includes/filerepo/File.php
includes/specials/SpecialUploadStash.php [new file with mode: 0644]
includes/upload/UploadBase.php
includes/upload/UploadFromFile.php
includes/upload/UploadStash.php [new file with mode: 0644]
maintenance/tests/phpunit/Makefile
maintenance/tests/phpunit/includes/api/ApiUploadTest.php [new file with mode: 0644]
maintenance/tests/phpunit/includes/api/RandomImageGenerator.php [new file with mode: 0644]
maintenance/tests/phpunit/includes/api/generateRandomImages.php [new file with mode: 0644]

index 8d7e76b..4bb9d66 100644 (file)
@@ -634,6 +634,7 @@ $wgAutoloadLocalClasses = array(
        'SpecialRecentChanges' => 'includes/specials/SpecialRecentchanges.php',
        'SpecialRecentchangeslinked' => 'includes/specials/SpecialRecentchangeslinked.php',
        'SpecialSearch' => 'includes/specials/SpecialSearch.php',
+       'SpecialUploadStash' => 'includes/specials/SpecialUploadStash.php',
        'SpecialSpecialpages' => 'includes/specials/SpecialSpecialpages.php',
        'SpecialStatistics' => 'includes/specials/SpecialStatistics.php',
        'SpecialTags' => 'includes/specials/SpecialTags.php',
@@ -669,6 +670,7 @@ $wgAutoloadLocalClasses = array(
        'UserloginTemplate' => 'includes/templates/Userlogin.php',
 
        # includes/upload
+       'UploadStash' => 'includes/upload/UploadStash.php',
        'UploadBase' => 'includes/upload/UploadBase.php',
        'UploadFromStash' => 'includes/upload/UploadFromStash.php',
        'UploadFromFile' => 'includes/upload/UploadFromFile.php',
index db267fc..859865e 100644 (file)
@@ -149,6 +149,7 @@ class SpecialPage {
                'MIMEsearch'                => array( 'SpecialPage', 'MIMEsearch' ),
                'FileDuplicateSearch'       => array( 'SpecialPage', 'FileDuplicateSearch' ),
                'Upload'                    => 'SpecialUpload',
+               'UploadStash'        => 'SpecialUploadStash',
 
                # Wiki data and tools
                'Statistics'                => 'SpecialStatistics',
index 1973d93..de4d5f8 100644 (file)
@@ -36,8 +36,13 @@ if ( !defined( 'MEDIAWIKI' ) ) {
  */
 class ApiQueryImageInfo extends ApiQueryBase {
 
-       public function __construct( $query, $moduleName ) {
-               parent::__construct( $query, $moduleName, 'ii' );
+       public function __construct( $query, $moduleName, $prefix = 'ii' ) {
+               // We allow a subclass to override the prefix, to create a related API module.
+               // Some other parts of MediaWiki construct this with a null $prefix, which used to be ignored when this only took two arguments
+               if ( is_null( $prefix ) ) {
+                       $prefix = 'ii';
+               }
+               parent::__construct( $query, $moduleName, $prefix );
        }
 
        public function execute() {
@@ -45,17 +50,7 @@ class ApiQueryImageInfo extends ApiQueryBase {
 
                $prop = array_flip( $params['prop'] );
 
-               if ( $params['urlheight'] != - 1 && $params['urlwidth'] == - 1 ) {
-                       $this->dieUsage( 'iiurlheight cannot be used without iiurlwidth', 'iiurlwidth' );
-               }
-
-               if ( $params['urlwidth'] != - 1 ) {
-                       $scale = array();
-                       $scale['width'] = $params['urlwidth'];
-                       $scale['height'] = $params['urlheight'];
-               } else {
-                       $scale = null;
-               }
+               $scale = $this->getScale( $params );
 
                $pageIds = $this->getPageSet()->getAllTitlesByNamespace();
                if ( !empty( $pageIds[NS_FILE] ) ) {
@@ -183,6 +178,28 @@ class ApiQueryImageInfo extends ApiQueryBase {
                }
        }
 
+       /**
+        * From parameters, construct a 'scale' array 
+        * @param {Array} $params 
+        * @return {null|Array} key-val array of 'width' and 'height', or null
+        */     
+       public function getScale( $params ) {
+               $p = $this->getModulePrefix();
+               if ( $params['urlheight'] != -1 && $params['urlwidth'] == -1 ) {
+                       $this->dieUsage( "${p}urlheight cannot be used without {$p}urlwidth", "{$p}urlwidth" );
+               }
+
+               if ( $params['urlwidth'] != -1 ) {
+                       $scale = array();
+                       $scale['width'] = $params['urlwidth'];
+                       $scale['height'] = $params['urlheight'];
+               } else {
+                       $scale = null;
+               }
+               return $scale;
+       }
+
+
        /**
         * Get result information for an image revision
         *
@@ -324,11 +341,11 @@ class ApiQueryImageInfo extends ApiQueryBase {
                        ),
                        'urlwidth' => array(
                                ApiBase::PARAM_TYPE => 'integer',
-                               ApiBase::PARAM_DFLT => - 1
+                               ApiBase::PARAM_DFLT => -1
                        ),
                        'urlheight' => array(
                                ApiBase::PARAM_TYPE => 'integer',
-                               ApiBase::PARAM_DFLT => - 1
+                               ApiBase::PARAM_DFLT => -1
                        ),
                        'continue' => null,
                );
@@ -356,6 +373,11 @@ class ApiQueryImageInfo extends ApiQueryBase {
                );
        }
 
+
+       /**
+        * Return the API documentation for the parameters. 
+        * @return {Array} parameter documentation.
+        */
        public function getParamDescription() {
                $p = $this->getModulePrefix();
                return array(
@@ -375,14 +397,14 @@ class ApiQueryImageInfo extends ApiQueryBase {
                                ' metadata      - Lists EXIF metadata for the version of the image',
                                ' archivename   - Adds the file name of the archive version for non-latest versions',
                                ' bitdepth      - Adds the bit depth of the version',
-            ),
-                       'limit' => 'How many image revisions to return',
-                       'start' => 'Timestamp to start listing from',
-                       'end' => 'Timestamp to stop listing at',
+                       ),
                        'urlwidth' => array( "If {$p}prop=url is set, a URL to an image scaled to this width will be returned.",
                                            'Only the current version of the image can be scaled' ),
                        'urlheight' => "Similar to {$p}urlwidth. Cannot be used without {$p}urlwidth",
-                       'continue' => 'When more results are available, use this to continue',
+                       'limit' => 'How many image revisions to return',
+                       'start' => 'Timestamp to start listing from',
+                       'end' => 'Timestamp to stop listing at',
+                       'continue' => 'If the query response includes a continue value, use it here to get another page of results'
                );
        }
 
index f423347..e7d7b93 100644 (file)
@@ -80,24 +80,65 @@ class ApiUpload extends ApiBase {
                // Check permission to upload this file
                $permErrors = $this->mUpload->verifyPermissions( $wgUser );
                if ( $permErrors !== true ) {
-                       // Todo: stash the upload and allow choosing a new name
+                       // TODO: stash the upload and allow choosing a new name
                        $this->dieUsageMsg( array( 'badaccess-groups' ) );
                }
 
-               // Check warnings if necessary
-               $warnings = $this->checkForWarnings();
-               if ( $warnings ) {
-                       $this->getResult()->addValue( null, $this->getModuleName(), $warnings );
+               // Prepare the API result
+               $result = array();
+               
+               $warnings = $this->getApiWarnings();
+               if ( $warnings ) { 
+                       $result['result'] = 'Warning';
+                       $result['warnings'] = $warnings;
+                       // in case the warnings can be fixed with some further user action, let's stash this upload
+                       // and return a key they can use to restart it
+                       try { 
+                               $result['sessionkey'] = $this->performStash();
+                       } catch ( MWException $e ) { 
+                               $result['warnings']['stashfailed'] = $e->getMessage();
+                       }
+               } elseif ( $this->mParams['stash'] ) { 
+                       // Some uploads can request they be stashed, so as not to publish them immediately.
+                       // In this case, a failure to stash ought to be fatal
+                       try {
+                               $result['result'] = 'Success'; 
+                               $result['sessionkey'] = $this->performStash();
+                       } catch ( MWException $e ) { 
+                               $this->dieUsage( $e->getMessage(), 'stashfailed' );
+                       }
                } else {
-                       // Perform the upload
+                       // This is the most common case -- a normal upload with no warnings
+                       // $result will be formatted properly for the API already, with a status
                        $result = $this->performUpload();
-                       $this->getResult()->addValue( null, $this->getModuleName(), $result );
                }
 
+               if ( $result['result'] === 'Success' ) { 
+                       $result['imageinfo'] = $this->mUpload->getImageInfo( $this->getResult() );
+               }
+
+               $this->getResult()->addValue( null, $this->getModuleName(), $result );
+               
                // Cleanup any temporary mess
                $this->mUpload->cleanupTempFile();
        }
 
+       /**
+        * Stash the file and return the session key
+        * Also re-raises exceptions with slightly more informative message strings (useful for API)
+        * @throws MWException
+        * @return {String} session key
+        */
+       function performStash() {
+               try {
+                       $sessionKey = $this->mUpload->stashSessionFile()->getSessionKey();
+               } catch ( MWException $e ) {
+                       throw new MWException( 'Stashing temporary file failed: ' . get_class($e) . ' ' . $e->getMessage() );
+               }
+               return $sessionKey;
+       }
+
+
        /**
         * Select an upload module and set it to mUpload. Dies on failure. If the
         * request was a status request and not a true upload, returns false; 
@@ -106,13 +147,14 @@ class ApiUpload extends ApiBase {
         * @return bool
         */
        protected function selectUploadModule() {
+               global $wgAllowAsyncCopyUploads;
                $request = $this->getMain()->getRequest();
 
                // One and only one of the following parameters is needed
                $this->requireOnlyOneParameter( $this->mParams,
                        'sessionkey', 'file', 'url', 'statuskey' );
 
-               if ( isset( $this->mParams['statuskey'] ) ) {
+               if ( $wgAllowAsyncCopyUploads && $this->mParams['statuskey'] ) {
                        // Status request for an async upload
                        $sessionData = UploadFromUrlJob::getSessionData( $this->mParams['statuskey'] );
                        if ( !isset( $sessionData['result'] ) ) {
@@ -126,12 +168,14 @@ class ApiUpload extends ApiBase {
                        return false;
                        
                } 
-               
+
+
                // The following modules all require the filename parameter to be set
                if ( is_null( $this->mParams['filename'] ) ) {
                        $this->dieUsageMsg( array( 'missingparam', 'filename' ) );
                }
-               
+                       
+
                if ( $this->mParams['sessionkey'] ) {
                        // Upload stashed in a previous request
                        $sessionData = $request->getSessionData( UploadBase::getSessionKeyName() );
@@ -249,56 +293,41 @@ class ApiUpload extends ApiBase {
                }
        }
 
+
        /**
         * Check warnings if ignorewarnings is not set.
-        * Returns a suitable result array if there were warnings
+        * Returns a suitable array for inclusion into API results if there were warnings
+        * Returns the empty array if there were no warnings
+        *
+        * @return array
         */
-       protected function checkForWarnings() {
-               $result = array();
+       protected function getApiWarnings() {
+               $warnings = array();
 
                if ( !$this->mParams['ignorewarnings'] ) {
                        $warnings = $this->mUpload->checkWarnings();
                        if ( $warnings ) {
-                               $result['result'] = 'Warning';
-                               $result['warnings'] = $this->transformWarnings( $warnings );
-
-                               $sessionKey = $this->mUpload->stashSession();
-                               if ( !$sessionKey ) {
-                                       $this->dieUsage( 'Stashing temporary file failed', 'stashfailed' );
+                               // Add indices
+                               $this->getResult()->setIndexedTagName( $warnings, 'warning' );
+
+                               if ( isset( $warnings['duplicate'] ) ) {
+                                       $dupes = array();
+                                       foreach ( $warnings['duplicate'] as $dupe ) {
+                                               $dupes[] = $dupe->getName();
+                                       }
+                                       $this->getResult()->setIndexedTagName( $dupes, 'duplicate' );
+                                       $warnings['duplicate'] = $dupes;
                                }
 
-                               $result['sessionkey'] = $sessionKey;
-
-                               return $result;
-                       }
-               }
-               return;
-       }
-       
-       /**
-        * Transforms a warnings array returned by mUpload->checkWarnings() into
-        * something that can be directly used as API result
-        */
-       protected function transformWarnings( $warnings ) {
-               // Add indices
-               $this->getResult()->setIndexedTagName( $warnings, 'warning' );
-
-               if ( isset( $warnings['duplicate'] ) ) {
-                       $dupes = array();
-                       foreach ( $warnings['duplicate'] as $dupe ) {
-                               $dupes[] = $dupe->getName();
+                               if ( isset( $warnings['exists'] ) ) {
+                                       $warning = $warnings['exists'];
+                                       unset( $warnings['exists'] );
+                                       $warnings[$warning['warning']] = $warning['file']->getName();
+                               }
                        }
-                       $this->getResult()->setIndexedTagName( $dupes, 'duplicate' );
-                       $warnings['duplicate'] = $dupes;
                }
 
-               if ( isset( $warnings['exists'] ) ) {
-                       $warning = $warnings['exists'];
-                       unset( $warnings['exists'] );
-                       $warnings[$warning['warning']] = $warning['file']->getName();
-               }
-               
-               return $warnings;       
+               return $warnings;
        }
 
        /**
@@ -346,7 +375,7 @@ class ApiUpload extends ApiBase {
 
                $result['result'] = 'Success';
                $result['filename'] = $file->getName();
-               $result['imageinfo'] = $this->mUpload->getImageInfo( $this->getResult() );
+
 
                return $result;
        }
@@ -384,8 +413,8 @@ class ApiUpload extends ApiBase {
                        'ignorewarnings' => false,
                        'file' => null,
                        'url' => null,
-
                        'sessionkey' => null,
+                       'stash' => false,
                );
 
                global $wgAllowAsyncCopyUploads;
@@ -410,7 +439,8 @@ class ApiUpload extends ApiBase {
                        'ignorewarnings' => 'Ignore any warnings',
                        'file' => 'File contents',
                        'url' => 'Url to fetch the file from',
-                       'sessionkey' => 'Session key returned by a previous upload that failed due to warnings',
+                       'sessionkey' => 'Session key that identifies a previous upload that was stashed temporarily.',
+                       'stash' => 'If set, the server will not add the file to the repository and stash it temporarily.'
                );
 
                global $wgAllowAsyncCopyUploads;
index 7b81f17..9e582ad 100644 (file)
@@ -541,7 +541,7 @@ abstract class File {
         * @param $params Array: an associative array of handler-specific parameters.
         *                Typical keys are width, height and page.
         * @param $flags Integer: a bitfield, may contain self::RENDER_NOW to force rendering
-        * @return MediaTransformOutput
+        * @return MediaTransformOutput | false
         */
        function transform( $params, $flags = 0 ) {
                global $wgUseSquid, $wgIgnoreImageErrors, $wgThumbnailEpoch, $wgServer;
@@ -575,7 +575,7 @@ abstract class File {
                        $thumbPath = $this->getThumbPath( $thumbName );
                        $thumbUrl = $this->getThumbUrl( $thumbName );
 
-                       if ( $this->repo->canTransformVia404() && !($flags & self::RENDER_NOW ) ) {
+                       if ( $this->repo && $this->repo->canTransformVia404() && !($flags & self::RENDER_NOW ) ) {
                                $thumb = $this->handler->getTransform( $this, $thumbPath, $thumbUrl, $params );
                                break;
                        }
diff --git a/includes/specials/SpecialUploadStash.php b/includes/specials/SpecialUploadStash.php
new file mode 100644 (file)
index 0000000..e28203a
--- /dev/null
@@ -0,0 +1,140 @@
+<?php
+/**
+ * Special:UploadStash
+ *
+ * Web access for files temporarily stored by UploadStash.
+ *
+ * For example -- files that were uploaded with the UploadWizard extension are stored temporarily 
+ * before committing them to the db. But we want to see their thumbnails and get other information
+ * about them.
+ *
+ * Since this is based on the user's session, in effect this creates a private temporary file area.
+ * However, the URLs for the files cannot be shared.
+ *
+ * @file
+ * @ingroup SpecialPage
+ * @ingroup Upload
+ */
+
+class SpecialUploadStash extends SpecialPage {
+
+       static $HttpErrors = array( // FIXME: Use OutputPage::getStatusMessage() --RK
+               400 => 'Bad Request',
+               403 => 'Access Denied',
+               404 => 'File not found',
+               500 => 'Internal Server Error',
+       );
+
+       // UploadStash
+       private $stash;
+
+       // we should not be reading in really big files and serving them out
+       private $maxServeFileSize = 262144; // 256K
+
+       // $request is the request (usually wgRequest)
+       // $subpage is everything in the URL after Special:UploadStash
+       // FIXME: These parameters don't match SpecialPage::__construct()'s params at all, and are unused --RK
+       public function __construct( $request = null, $subpage = null ) {
+                parent::__construct( 'UploadStash', 'upload' );
+               $this->stash = new UploadStash();
+       }
+
+       /**
+        * If file available in stash, cats it out to the client as a simple HTTP response.
+        * n.b. Most sanity checking done in UploadStashLocalFile, so this is straightforward.
+        * 
+        * @param {String} $subPage: subpage, e.g. in http://example.com/wiki/Special:UploadStash/foo.jpg, the "foo.jpg" part
+        * @return {Boolean} success 
+        */
+       public function execute( $subPage ) {
+               global $wgOut, $wgUser;
+               
+               if ( !$this->userCanExecute( $wgUser ) ) {
+                       $this->displayRestrictionError();
+                       return;
+               }
+
+               // prevent callers from doing standard HTML output -- we'll take it from here
+               $wgOut->disable();
+
+               try { 
+                       $file = $this->getStashFile( $subPage );
+                       if ( $file->getSize() > $this->maxServeFileSize ) {
+                               throw new MWException( 'file size too large' );
+                       }
+                       $this->outputFile( $file );
+                       return true;
+
+               } catch( UploadStashFileNotFoundException $e ) {
+                       $code = 404;
+               } catch( UploadStashBadPathException $e ) {
+                       $code = 403;
+               } catch( Exception $e ) {
+                       $code = 500;
+               }
+                       
+               wfHttpError( $code, self::$HttpErrors[$code], $e->getCode(), $e->getMessage() );
+               return false;
+       }
+
+
+       /** 
+        * Convert the incoming url portion (subpage of Special page) into a stashed file, if available.
+        * @param {String} $subPage 
+        * @return {File} file object
+        * @throws MWException, UploadStashFileNotFoundException, UploadStashBadPathException
+        */
+       private function getStashFile( $subPage ) {
+               // due to an implementation quirk (and trying to be compatible with older method) 
+               // the stash key doesn't have an extension 
+               $key = $subPage;
+               $n = strrpos( $subPage, '.' );
+                if ( $n !== false ) {
+                        $key = $n ? substr( $subPage, 0, $n ) : $subPage;
+               }
+
+               try {
+                       $file = $this->stash->getFile( $key );
+               } catch ( UploadStashFileNotFoundException $e ) { 
+                       // if we couldn't find it, and it looks like a thumbnail,
+                       // and it looks like we have the original, go ahead and generate it
+                       $matches = array();
+                       if ( ! preg_match( '/^(\d+)px-(.*)$/', $key, $matches ) ) {
+                               // that doesn't look like a thumbnail. re-raise exception 
+                               throw $e;
+                       }
+
+                       list( $dummy, $width, $origKey ) = $matches;
+
+                       // do not trap exceptions, if key is in bad format, or file not found,
+                       // let exceptions propagate to caller.
+                       $origFile = $this->stash->getFile( $origKey );
+
+                       // ok we're here so the original must exist. Generate the thumbnail. 
+                       // because the file is a UploadStashFile, this thumbnail will also be stashed,
+                       // and a thumbnailFile will be created in the thumbnailImage composite object
+                       $thumbnailImage = null;
+                       if ( !( $thumbnailImage = $origFile->getThumbnail( $width ) ) ) { 
+                               throw new MWException( 'Could not obtain thumbnail' );
+                       }
+                       $file = $thumbnailImage->thumbnailFile;
+               }
+               return $file;
+       }
+
+       /**
+        * Output HTTP response for file
+        * Side effects, obviously, of echoing lots of stuff to stdout.
+        * @param {File} file
+        */             
+       private function outputFile( $file ) { 
+               header( 'Content-Type: ' . $file->getMimeType(), true );
+               header( 'Content-Transfer-Encoding: binary', true );
+               header( 'Expires: Sun, 17-Jan-2038 19:14:07 GMT', true );
+               header( 'Pragma: public', true );
+               header( 'Content-Length: ' . $file->getSize(), true ); // FIXME: PHP can handle Content-Length for you just fine --RK
+               readfile( $file->getPath() );
+       }
+}
+
index e09ac0f..4c2e507 100644 (file)
@@ -600,6 +600,9 @@ abstract class UploadBase {
        }
 
        /**
+        * NOTE: Probably should be deprecated in favor of UploadStash, but this is sometimes
+        * called outside that context.
+        *
         * Stash a file in a temporary directory for later processing
         * after the user has confirmed it.
         *
@@ -617,40 +620,36 @@ abstract class UploadBase {
        }
 
        /**
-        * Stash a file in a temporary directory for later processing,
-        * and save the necessary descriptive info into the session.
-        * Returns a key value which will be passed through a form
-        * to pick up the path info on a later invocation.
+        * If the user does not supply all necessary information in the first upload form submission (either by accident or
+        * by design) then we may want to stash the file temporarily, get more information, and publish the file later.
+        *
+        * This method will stash a file in a temporary directory for later processing, and save the necessary descriptive info
+        * into the user's session.
+        * This method returns the file object, which also has a 'sessionKey' property which can be passed through a form or 
+        * API request to find this stashed file again.
         *
-        * @return Integer: session key
+        * @param {String}: $key (optional) the session key used to find the file info again. If not supplied, a key will be autogenerated.
+        * @return {File}: stashed file
         */
-       public function stashSession( $key = null ) {
-               $status = $this->saveTempUploadedFile( $this->mDestName, $this->mTempPath );
-               if( !$status->isOK() ) {
-                       # Couldn't save the file.
-                       return false;
-               }
-
-               if ( is_null( $key ) ) {
-                       $key = $this->getSessionKey();
-               }
-               $_SESSION[self::SESSION_KEYNAME][$key] = array(
-                       'mTempPath'       => $status->value,
-                       'mFileSize'       => $this->mFileSize,
-                       'mFileProps'      => $this->mFileProps,
-                       'version'         => self::SESSION_VERSION,
+       public function stashSessionFile( $key = null ) { 
+               $stash = new UploadStash();
+               $data = array( 
+                       'mFileProps' => $this->mFileProps
                );
-               return $key;
+               $file = $stash->stashFile( $this->mTempPath, $data, $key );
+               // TODO should we change the "local file" here? 
+               // $this->mLocalFile = $file;
+               return $file;
        }
 
        /**
-        * Generate a random session key from stash in cases where we want
-        * to start an upload without much information
+        * Stash a file in a temporary directory, returning a key which can be used to find the file again. See stashSessionFile().
+        *
+        * @param {String}: $key (optional) the session key used to find the file info again. If not supplied, a key will be autogenerated.
+        * @return {String}: session key
         */
-       protected function getSessionKey() {
-               $key = mt_rand( 0, 0x7fffffff );
-               $_SESSION[self::SESSION_KEYNAME][$key] = array();
-               return $key;
+       public function stashSession( $key = null ) {
+               return $this->stashSessionFile( $key )->getSessionKey();
        }
 
        /**
@@ -1197,12 +1196,23 @@ abstract class UploadBase {
                return $blacklist;
        }
 
+       /**
+        * Gets image info about the file just uploaded. 
+        *
+        * Also has the effect of setting metadata to be an 'indexed tag name' in returned API result if 
+        * 'metadata' was requested. Oddly, we have to pass the "result" object down just so it can do that
+        * with the appropriate format, presumably. 
+        *
+        * @param {ApiResult} 
+        * @return {Array} image info
+        */
        public function getImageInfo( $result ) {
                $file = $this->getLocalFile();
                $imParam = ApiQueryImageInfo::getPropertyNames();
                return ApiQueryImageInfo::getInfo( $file, array_flip( $imParam ), $result );
        }
 
+
        public function convertVerifyErrorToStatus( $error ) {
                $code = $error['status'];
                unset( $code['status'] );
index 9ca9229..7993208 100644 (file)
@@ -52,4 +52,14 @@ class UploadFromFile extends UploadBase {
                
                return parent::verifyUpload();
        }
+
+       /** 
+        * Get the path to the file underlying the upload
+        * @return String path to file
+        */
+       public function getFileTempname() {
+               return $this->mUpload->getTempname();
+       }
+
+       
 }
diff --git a/includes/upload/UploadStash.php b/includes/upload/UploadStash.php
new file mode 100644 (file)
index 0000000..3e4467a
--- /dev/null
@@ -0,0 +1,406 @@
+<?php
+/** 
+ * UploadStash is intended to accomplish a few things:
+ *   - enable applications to temporarily stash files without publishing them to the wiki.
+ *      - Several parts of MediaWiki do this in similar ways: UploadBase, UploadWizard, and FirefoggChunkedExtension
+ *        And there are several that reimplement stashing from scratch, in idiosyncratic ways. The idea is to unify them all here.
+ *       Mostly all of them are the same except for storing some custom fields, which we subsume into the data array.
+ *   - enable applications to find said files later, as long as the session or temp files haven't been purged. 
+ *   - enable the uploading user (and *ONLY* the uploading user) to access said files, and thumbnails of said files, via a URL.
+ *     We accomplish this by making the session serve as a URL->file mapping, on the assumption that nobody else can access 
+ *     the session, even the uploading user. See SpecialUploadStash, which implements a web interface to some files stored this way.
+ *
+ */
+class UploadStash {
+       // Format of the key for files -- has to be suitable as a filename itself in some cases.
+       // This should encompass a sha1 content hash in hex (new style), or an integer (old style), 
+       // and also thumbnails with prepended strings like "120px-". 
+       // The file extension should not be part of the key.
+       const KEY_FORMAT_REGEX = '/^[\w-]+$/';
+
+       // repository that this uses to store temp files
+       protected $repo; 
+       
+       // array of initialized objects obtained from session (lazily initialized upon getFile())
+       private $files = array();  
+
+       // the base URL for files in the stash
+       private $baseUrl;
+       
+       // TODO: Once UploadBase starts using this, switch to use these constants rather than UploadBase::SESSION*
+       // const SESSION_VERSION = 2;
+       // const SESSION_KEYNAME = 'wsUploadData';
+
+       /**
+        * Represents the session which contains temporarily stored files.
+        * Designed to be compatible with the session stashing code in UploadBase (should replace it eventually)
+        * @param {FileRepo} $repo: optional -- repo in which to store files. Will choose LocalRepo if not supplied.
+        */
+       public function __construct( $repo = null ) { 
+
+               if ( is_null( $repo ) ) {
+                       $repo = RepoGroup::singleton()->getLocalRepo();
+               }
+
+               $this->repo = $repo;
+
+               if ( ! isset( $_SESSION ) ) {
+                       throw new UploadStashNotAvailableException( 'no session variable' );
+               }
+
+               if ( !isset( $_SESSION[UploadBase::SESSION_KEYNAME] ) ) {
+                       $_SESSION[UploadBase::SESSION_KEYNAME] = array();
+               }
+               
+               $this->baseUrl = SpecialPage::getTitleFor( 'UploadStash' )->getLocalURL(); 
+       }
+
+       /**
+        * Get the base of URLs by which one can access the files 
+        * @return {String} url
+        */
+       public function getBaseUrl() { 
+               return $this->baseUrl;
+       }
+
+       /** 
+        * Get a file and its metadata from the stash.
+        * May throw exception if session data cannot be parsed due to schema change, or key not found.
+        * @param {Integer} $key: key
+        * @throws UploadStashFileNotFoundException
+        * @throws UploadStashBadVersionException
+        * @return {UploadStashItem} null if no such item or item out of date, or the item
+        */
+       public function getFile( $key ) {
+               if ( ! preg_match( self::KEY_FORMAT_REGEX, $key ) ) {
+                       throw new UploadStashBadPathException( "key '$key' is not in a proper format" );
+               } 
+               if ( !isset( $this->files[$key] ) ) {
+                       if ( !isset( $_SESSION[UploadBase::SESSION_KEYNAME][$key] ) ) {
+                               throw new UploadStashFileNotFoundException( "key '$key' not found in session" );
+                       }
+
+                       $data = $_SESSION[UploadBase::SESSION_KEYNAME][$key];
+                       // guards against PHP class changing while session data doesn't
+                       if ($data['version'] !== UploadBase::SESSION_VERSION ) {
+                               throw new UploadStashBadVersionException( $data['version'] . " does not match current version " . UploadBase::SESSION_VERSION );
+                       }
+               
+                       // separate the stashData into the path, and then the rest of the data
+                       $path = $data['mTempPath'];
+                       unset( $data['mTempPath'] );
+
+                       $file = new UploadStashFile( $this, $this->repo, $path, $key, $data );
+                       
+                       $this->files[$key] = $file;
+
+               }
+               return $this->files[$key];
+       }
+
+       /**
+        * Stash a file in a temp directory and record that we did this in the session, along with other metadata.
+        * We store data in a flat key-val namespace because that's how UploadBase did it. This also means we have to
+        * ensure that the key-val pairs in $data do not overwrite other required fields.
+        *
+        * @param {String} $path: path to file you want stashed
+        * @param {Array} $data: optional, other data you want associated with the file. Do not use 'mTempPath', 'mFileProps', 'mFileSize', or 'version' as keys here
+        * @param {String} $key: optional, unique key for this file in this session. Used for directory hashing when storing, otherwise not important
+        * @throws UploadStashBadPathException
+        * @throws UploadStashFileException
+        * @return {null|UploadStashFile} file, or null on failure
+        */
+       public function stashFile( $path, $data = array(), $key = null ) {
+               if ( ! file_exists( $path ) ) {
+                       throw new UploadStashBadPathException( "path '$path' doesn't exist" );
+               }
+                $fileProps = File::getPropsFromPath( $path );
+
+               // If no key was supplied, use content hash. Also has the nice property of collapsing multiple identical files
+               // uploaded this session, which could happen if uploads had failed.
+               if ( is_null( $key ) ) {
+                       $key = $fileProps['sha1'];
+               }
+
+               if ( ! preg_match( self::KEY_FORMAT_REGEX, $key ) ) {
+                       throw new UploadStashBadPathException( "key '$key' is not in a proper format" );
+               } 
+
+               // if not already in a temporary area, put it there 
+               $status = $this->repo->storeTemp( basename( $path ), $path );
+               if( ! $status->isOK() ) {
+                       // It is a convention in MediaWiki to only return one error per API exception, even if multiple errors
+                       // are available. We use reset() to pick the "first" thing that was wrong, preferring errors to warnings.
+                       // This is a bit lame, as we may have more info in the $status and we're throwing it away, but to fix it means
+                       // redesigning API errors significantly.
+                       // $status->value just contains the virtual URL (if anything) which is probably useless to the caller
+                       $error = reset( $status->getErrorsArray() );
+                       if ( ! count( $error ) ) {
+                               $error = reset( $status->getWarningsArray() );
+                               if ( ! count( $error ) ) {
+                                       $error = array( 'unknown', 'no error recorded' );
+                               }
+                       }
+                       throw new UploadStashFileException( "error storing file in '$path': " . implode( '; ', $error ) );
+               }
+               $stashPath = $status->value;
+                               
+               // required info we always store. Must trump any other application info in $data
+               // 'mTempPath', 'mFileSize', and 'mFileProps' are arbitrary names
+               // chosen for compatibility with UploadBase's way of doing this.
+               $requiredData = array( 
+                       'mTempPath' => $stashPath,
+                       'mFileSize' => $fileProps['size'],
+                       'mFileProps' => $fileProps,
+                       'version' => UploadBase::SESSION_VERSION
+               );
+
+               // now, merge required info and extra data into the session. (The extra data changes from application to application.
+               // UploadWizard wants different things than say FirefoggChunkedUpload.)
+               $_SESSION[UploadBase::SESSION_KEYNAME][$key] = array_merge( $data, $requiredData );
+               
+               return $this->getFile( $key );
+       }
+
+}
+
+class UploadStashFile extends UnregisteredLocalFile {
+       private $sessionStash;
+       private $sessionKey;
+       private $sessionData;
+       private $urlName;
+
+       /**
+        * A LocalFile wrapper around a file that has been temporarily stashed, so we can do things like create thumbnails for it
+        * Arguably UnregisteredLocalFile should be handling its own file repo but that class is a bit retarded currently
+        * @param {UploadStash} $stash: UploadStash, useful for obtaining config, stashing transformed files
+        * @param {FileRepo} $repo: repository where we should find the path
+        * @param {String} $path: path to file
+        * @param {String} $key: key to store the path and any stashed data under
+        * @param {String} $data: any other data we want stored with this file
+        * @throws UploadStashBadPathException
+        * @throws UploadStashFileNotFoundException
+        */
+       public function __construct( $stash, $repo, $path, $key, $data ) { 
+               $this->sessionStash = $stash;
+               $this->sessionKey = $key;
+               $this->sessionData = $data;
+
+               // resolve mwrepo:// urls
+               if ( $repo->isVirtualUrl( $path ) ) {
+                       $path = $repo->resolveVirtualUrl( $path );      
+               }
+
+               // check if path appears to be sane, no parent traversals, and is in this repo's temp zone.
+               $repoTempPath = $repo->getZonePath( 'temp' );
+               if ( ( ! $repo->validateFilename( $path ) ) || 
+                               ( strpos( $path, $repoTempPath ) !== 0 ) ) {
+                       throw new UploadStashBadPathException( "path '$path' is not valid or is not in repo temp area: '$repoTempPath'" );
+               }
+
+               // check if path exists! and is a plain file.
+               if ( ! $repo->fileExists( $path, FileRepo::FILES_ONLY ) ) {
+                       throw new UploadStashFileNotFoundException( "cannot find path '$path'" );
+               }
+
+               parent::__construct( false, $repo, $path, false );
+
+               // we will be initializing from some tmpnam files that don't have extensions.
+               // most of MediaWiki assumes all uploaded files have good extensions. So, we fix this.
+               $this->name = basename( $this->path );
+               $this->setExtension();
+
+       }
+
+       /**
+        * A method needed by the file transforming and scaling routines in File.php
+        * We do not necessarily care about doing the description at this point
+        * However, we also can't return the empty string, as the rest of MediaWiki demands this (and calls to imagemagick
+        * convert require it to be there)
+        * @return {String} dummy value
+        */
+       public function getDescriptionUrl() {
+               return $this->getUrl();
+       }
+
+       /**
+        * Find or guess extension -- ensuring that our extension matches our mime type.
+        * Since these files are constructed from php tempnames they may not start off 
+        * with an extension.
+        * This does not override getExtension() because things like getMimeType() already call getExtension(),
+        * and that results in infinite recursion. So, we preemptively *set* the extension so getExtension() can find it.
+        * For obvious reasons this should be called as early as possible, as part of initialization
+        */
+       public function setExtension() {        
+               // Does this have an extension?
+               $n = strrpos( $this->path, '.' );
+               $extension = null;
+               if ( $n !== false ) {
+                       $extension = $n ? substr( $this->path, $n + 1 ) : '';
+               } else {
+                       // If not, assume that it should be related to the mime type of the original file.
+                       //
+                       // This entire thing is backwards -- we *should* just create an extension based on 
+                       // the mime type of the transformed file, *after* transformation.  But File.php demands 
+                       // to know the name of the transformed file before creating it. 
+                       $mimeType = $this->getMimeType();
+                       $extensions = explode( ' ', MimeMagic::singleton()->getExtensionsForType( $mimeType ) );
+                       if ( count( $extensions ) ) { 
+                               $extension = $extensions[0];    
+                       }
+               }
+
+               if ( is_null( $extension ) ) {
+                       throw new UploadStashFileException( "extension '$extension' is null" );
+               }
+
+               $this->extension = parent::normalizeExtension( $extension );
+       }
+
+       /**
+        * Get the path for the thumbnail (actually any transformation of this file)
+        * The actual argument is the result of thumbName although we seem to have 
+        * buggy code elsewhere that expects a boolean 'suffix'
+        *
+        * @param {String|false} $thumbName: name of thumbnail (e.g. "120px-123456.jpg" ), or false to just get the path
+        * @return {String} path thumbnail should take on filesystem, or containing directory if thumbname is false
+        */
+       public function getThumbPath( $thumbName = false ) { 
+               $path = dirname( $this->path );
+               if ( $thumbName !== false ) {
+                       $path .= "/$thumbName";
+               }
+               return $path;
+       }
+
+       /**
+        * Return the file/url base name of a thumbnail with the specified parameters
+        *
+        * @param {Array} $params: handler-specific parameters
+        * @return {String|null} base name for URL, like '120px-12345.jpg', or null if there is no handler
+        */
+       function thumbName( $params ) {
+               if ( !$this->getHandler() ) {
+                       return null;
+               }
+               $extension = $this->getExtension();
+               list( $thumbExt, $thumbMime ) = $this->handler->getThumbType( $extension, $this->getMimeType(), $params );
+               $thumbName = $this->getHandler()->makeParamString( $params ) . '-' . $this->getUrlName();
+               if ( $thumbExt != $extension ) {
+                       $thumbName .= ".$thumbExt";
+               }
+               return $thumbName;
+       }
+
+       /** 
+        * Get a URL to access the thumbnail 
+        * This is required because the model of how files work requires that 
+        * the thumbnail urls be predictable. However, in our model the URL is not based on the filename
+        * (that's hidden in the session)
+        *
+        * @param {String} $thumbName: basename of thumbnail file -- however, we don't want to use the file exactly
+        * @return {String} URL to access thumbnail, or URL with partial path
+        */
+       public function getThumbUrl( $thumbName = false ) { 
+               $path = $this->sessionStash->getBaseUrl();
+               if ( $thumbName !== false ) {
+                       $path .= '/' . rawurlencode( $thumbName );
+               }
+               return $path;
+       }
+
+       /** 
+        * The basename for the URL, which we want to not be related to the filename.
+        * Will also be used as the lookup key for a thumbnail file.
+        * @return {String} base url name, like '120px-123456.jpg'
+        */
+       public function getUrlName() { 
+               if ( ! $this->urlName ) {
+                       $this->urlName = $this->sessionKey . '.' . $this->getExtension();
+               }
+               return $this->urlName;
+       }
+
+       /**
+        * Return the URL of the file, if for some reason we wanted to download it
+        * We tend not to do this for the original file, but we do want thumb icons
+        * @return {String} url
+        */
+       public function getUrl() {
+               if ( !isset( $this->url ) ) {
+                       $this->url = $this->sessionStash->getBaseUrl() . '/' . $this->getUrlName();
+               }
+               return $this->url;
+       }
+
+       /**
+        * Parent classes use this method, for no obvious reason, to return the path (relative to wiki root, I assume). 
+        * But with this class, the URL is unrelated to the path.
+        *
+        * @return {String} url
+        */
+       public function getFullUrl() { 
+               return $this->getUrl();
+       }
+
+
+       /**
+        * Getter for session key (the session-unique id by which this file's location & metadata is stored in the session)
+        * @return {String} session key
+        */
+       public function getSessionKey() {
+               return $this->sessionKey;
+       }
+
+       /**
+        * Typically, transform() returns a ThumbnailImage, which you can think of as being the exact
+        * equivalent of an HTML thumbnail on Wikipedia. So its URL is the full-size file, not the thumbnail's URL.
+        *
+        * Here we override transform() to stash the thumbnail file, and then 
+        * provide a way to get at the stashed thumbnail file to extract properties such as its URL
+        *
+        * @param {Array} $params: parameters suitable for File::transform()
+        * @param {Bitmask} $flags: flags suitable for File::transform()
+        * @return {ThumbnailImage} with additional File thumbnailFile property
+        */
+       public function transform( $params, $flags = 0 ) { 
+
+               // force it to get a thumbnail right away
+               $flags |= self::RENDER_NOW;
+
+               // returns a ThumbnailImage object containing the url and path. Note. NOT A FILE OBJECT.
+               $thumb = parent::transform( $params, $flags );
+               $key = $this->thumbName($params);
+
+               // remove extension, so it's stored in the session under '120px-123456'
+               // this makes it uniform with the other session key for the original, '123456'
+               $n = strrpos( $key, '.' );      
+               if ( $n !== false ) {
+                       $key = substr( $key, 0, $n );
+               }
+
+               // stash the thumbnail File, and provide our caller with a way to get at its properties
+               $stashedThumbFile = $this->sessionStash->stashFile( $thumb->path, array(), $key );
+               $thumb->thumbnailFile = $stashedThumbFile;
+
+               return $thumb;  
+
+       }
+
+       /**
+        * Remove the associated temporary file
+        * @return {Status} success
+        */
+       public function remove() {
+               return $this->repo->freeTemp( $this->path );
+       }
+
+}
+
+class UploadStashNotAvailableException extends MWException {};
+class UploadStashFileNotFoundException extends MWException {};
+class UploadStashBadPathException extends MWException {};
+class UploadStashBadVersionException extends MWException {};
+class UploadStashFileException extends MWException {};
+
index e14c5b1..9ec845e 100644 (file)
@@ -4,7 +4,8 @@
 SHELL = /bin/sh
 CONFIG_FILE = $(shell pwd)/suite.xml
 FLAGS = 
-PU = php phpunit.php --configuration ${CONFIG_FILE}
+PHP = php
+PU = ${PHP} phpunit.php --configuration ${CONFIG_FILE}
 
 all test: warning
 
@@ -73,3 +74,4 @@ help:
        #  Options:
        #   CONFIG_FILE         Path to a PHPUnit configuration file (default: suite.xml)
        #   FLAGS               Additional flags to pass to PHPUnit
+       #   PHP                 Path to php
diff --git a/maintenance/tests/phpunit/includes/api/ApiUploadTest.php b/maintenance/tests/phpunit/includes/api/ApiUploadTest.php
new file mode 100644 (file)
index 0000000..1388dd0
--- /dev/null
@@ -0,0 +1,645 @@
+<?php
+
+/**
+ * @group Database
+ * @group Destructive
+ */
+
+/**
+ * n.b. Ensure that you can write to the images/ directory as the 
+ * user that will run tests.
+ */
+
+// Note for reviewers: this intentionally duplicates functionality already in "ApiSetup" and so on.
+// This framework works better IMO and has less strangeness (such as test cases inheriting from "ApiSetup"...)
+// (and in the case of the other Upload tests, this flat out just actually works... )
+       
+// TODO: refactor into several files
+// TODO: port the other Upload tests, and other API tests to this framework
+
+require_once( dirname( __FILE__ ) . '/RandomImageGenerator.php' );
+
+/* Wraps the user object, so we can also retain full access to properties like password if we log in via the API */
+class ApiTestUser {
+       public $username;
+       public $password;
+       public $email;
+       public $groups;
+       public $user;
+
+       function __construct( $username, $realname = 'Real Name', $email = 'sample@sample.com', $groups = array() ) {
+               global $wgMinimalPasswordLength;
+
+               $this->username = $username; 
+               $this->realname = $realname; 
+               $this->email = $email;
+               $this->groups = $groups;
+
+               // don't allow user to hardcode or select passwords -- people sometimes run tests       
+               // on live wikis. Sometimes we create sysop users in these tests. A sysop user with
+               // a known password would be a Bad Thing.
+               $this->password = User::randomPassword();
+
+               $this->user = User::newFromName( $this->username );
+               $this->user->load();
+
+               // In an ideal world we'd have a new wiki (or mock data store) for every single test.
+               // But for now, we just need to create or update the user with the desired properties.
+               // we particularly need the new password, since we just generated it randomly.
+               // In core MediaWiki, there is no functionality to delete users, so this is the best we can do.
+               if ( !$this->user->getID() ) {
+                       // create the user
+                       $this->user = User::createNew( 
+                               $this->username, array(
+                                       "email" => $this->email,
+                                       "real_name" => $this->realname
+                               ) 
+                       );
+                       if ( !$this->user ) {
+                               throw new Exception( "error creating user" );
+                       }
+               }
+               
+               // update the user to use the new random password and other details
+               $this->user->setPassword( $this->password );
+               $this->user->setEmail( $this->email );
+               $this->user->setRealName( $this->realname );
+               // remove all groups, replace with any groups specified
+               foreach ( $this->user->getGroups() as $group ) {
+                       $this->user->removeGroup( $group );
+               }
+               if ( count( $this->groups ) ) {
+                       foreach ( $this->groups as $group ) {
+                               $this->user->addGroup( $group );
+                       }
+               }
+               $this->user->saveSettings();
+               
+       }
+
+}
+
+abstract class ApiTestCase extends PHPUnit_Framework_TestCase {
+       public static $users;
+
+       function setUp() {
+               global $wgServer, $wgContLang, $wgAuth, $wgMemc, $wgRequest, $wgUser;
+
+               $wgMemc = new FakeMemCachedClient();
+               $wgContLang = Language::factory( 'en' );
+               $wgAuth = new StubObject( 'wgAuth', 'AuthPlugin' );
+               $wgRequest = new FauxRequest( array() );
+
+               self::$users = array(
+                       'sysop' => new ApiTestUser( 
+                               'Apitestsysop', 
+                               'Api Test Sysop', 
+                               'api_test_sysop@sample.com', 
+                               array( 'sysop' )        
+                       ),
+                       'uploader' => new ApiTestUser( 
+                               'Apitestuser',
+                               'Api Test User',
+                               'api_test_user@sample.com',
+                               array()                 
+                       )
+               );
+
+               $wgUser = self::$users['sysop']->user;
+
+       }
+
+       function tearDown() {
+               global $wgMemc;
+               $wgMemc = null;
+       }
+
+       protected function doApiRequest( $params, $session = null ) {
+               $_SESSION = isset( $session ) ? $session : array();
+
+               $request = new FauxRequest( $params, true, $_SESSION );
+               $module = new ApiMain( $request, true );
+               $module->execute();
+
+               return array( $module->getResultData(), $request, $_SESSION );
+       }
+
+       /**
+        * Add an edit token to the API request
+        * This is cheating a bit -- we grab a token in the correct format and then add it to the pseudo-session and to the
+        * request, without actually requesting a "real" edit token
+        * @param $params: key-value API params
+        * @param $data: a structure which also contains the session
+        */
+       protected function doApiRequestWithToken( $params, $session ) {
+               if ( $session['wsToken'] ) {
+                       // add edit token to fake session
+                       $session['wsEditToken'] = $session['wsToken'];
+                       // add token to request parameters
+                       $params['token'] = md5( $session['wsToken'] ) . EDIT_TOKEN_SUFFIX;
+                       return $this->doApiRequest( $params, $session );
+               } else {
+                       throw new Exception( "request data not in right format" );
+               }
+       }
+
+}
+
+class ApiUploadTest extends ApiTestCase {
+       /**
+        * Fixture -- run before every test 
+        */
+       public function setUp() {
+               global $wgEnableUploads, $wgEnableAPI, $wgDebugLogFile;
+               parent::setUp();
+
+               $wgEnableUploads = true;
+               $wgEnableAPI = true;
+               wfSetupSession();
+
+               $wgDebugLogFile = '/private/tmp/mwtestdebug.log';
+               ini_set( 'log_errors', 1 );
+               ini_set( 'error_reporting', 1 );
+               ini_set( 'display_errors', 1 );
+               
+               $this->clearFakeUploads();
+       }
+
+       /**
+        * Fixture -- run after every test 
+        * Clean up temporary files etc.
+        */
+       function tearDown() {
+       }
+
+
+       /**
+        * Testing login
+        * XXX this is a funny way of getting session context
+        */
+       function testLogin() {
+               $user = self::$users['uploader'];
+
+               $params = array( 
+                       'action' => 'login', 
+                       'lgname' => $user->username, 
+                       'lgpassword' => $user->password 
+               );
+               list( $result, $request, $session ) = $this->doApiRequest( $params );
+               $this->assertArrayHasKey( "login", $result );
+               $this->assertArrayHasKey( "result", $result['login'] );
+               $this->assertEquals( "NeedToken", $result['login']['result'] );
+               $token = $result['login']['token'];
+
+               $params = array(
+                       'action' => 'login',
+                       'lgtoken' => $token,
+                       'lgname' => $user->username,
+                       'lgpassword' => $user->password 
+               ); 
+               list( $result, $request, $session ) = $this->doApiRequest( $params );
+               $this->assertArrayHasKey( "login", $result );
+               $this->assertArrayHasKey( "result", $result['login'] );
+               $this->assertEquals( "Success", $result['login']['result'] );
+               $this->assertArrayHasKey( 'lgtoken', $result['login'] );
+
+               return $session;
+
+       }
+
+       /**
+        * @depends testLogin
+        */
+       public function testUploadRequiresToken( $session ) { 
+               $exception = false;
+               try {
+                       $this->doApiRequest( array(
+                               'action' => 'upload'
+                       ) );
+               } catch ( UsageException $e ) {
+                       $exception = true;
+                       $this->assertEquals( "The token parameter must be set", $e->getMessage() );
+               }
+               $this->assertTrue( $exception, "Got exception" );
+       }
+
+       /**
+        * @depends testLogin
+        */     
+       public function testUploadMissingParams( $session ) { 
+               global $wgUser;
+               $wgUser = self::$users['uploader']->user;
+
+               $exception = false;
+               try {
+                       $this->doApiRequestWithToken( array(
+                               'action' => 'upload',
+                       ), $session );
+               } catch ( UsageException $e ) {
+                       $exception = true;
+                       $this->assertEquals( "One of the parameters sessionkey, file, url, statuskey is required",
+                               $e->getMessage() );
+               }
+               $this->assertTrue( $exception, "Got exception" );
+       }
+
+
+       /**
+        * @depends testLogin
+        */
+       public function testUpload( $session ) { 
+               global $wgUser;
+               $wgUser = self::$users['uploader']->user;
+
+               $extension = 'png';
+               $mimeType = 'image/png';
+
+               $randomImageGenerator = new RandomImageGenerator();
+               $filePaths = $randomImageGenerator->writeImages( 1, $extension, dirname( wfTempDir() ) );
+               $filePath = $filePaths[0];
+               $fileName = basename( $filePath ); 
+
+               $this->deleteFileByFileName( $fileName );
+               $this->deleteFileByContent( $filePath );
+
+               if (! $this->fakeUploadFile( 'file', $fileName, $mimeType, $filePath ) ) {
+                       $this->markTestIncomplete( "Couldn't upload file!\n" );
+               }
+
+               $params = array(
+                       'action' => 'upload',
+                       'filename' => $fileName,
+                       'file' => 'dummy content',
+                       'comment' => 'dummy comment',
+                       'text'  => "This is the page text for $fileName",
+               );
+
+               $exception = false;
+               try {
+                       list( $result, $request, $session ) = $this->doApiRequestWithToken( $params, $session );
+               } catch ( UsageException $e ) {
+                       $exception = true;
+               }
+               $this->assertTrue( isset( $result['upload'] ) );
+               $this->assertEquals( 'Success', $result['upload']['result'] );
+               $this->assertFalse( $exception );
+
+               // clean up
+               $this->deleteFileByFilename( $fileName );
+               unlink( $filePath );
+       }
+
+
+       /**
+        * @depends testLogin
+        */
+       public function testUploadZeroLength( $session ) { 
+               global $wgUser;
+               $wgUser = self::$users['uploader']->user;
+
+               $extension = 'png';
+               $mimeType = 'image/png';
+
+               $filePath = tempnam( wfTempDir(), "" );
+               $fileName = "apiTestUploadZeroLength.png";
+
+               $this->deleteFileByFileName( $fileName );
+
+               if (! $this->fakeUploadFile( 'file', $fileName, $mimeType, $filePath ) ) {
+                       $this->markTestIncomplete( "Couldn't upload file!\n" );
+               }
+
+               $params = array(
+                       'action' => 'upload',
+                       'filename' => $fileName,
+                       'file' => 'dummy content',
+                       'comment' => 'dummy comment',
+                       'text'  => "This is the page text for $fileName",
+               );
+
+               $exception = false;
+               try {
+                       list( $result, $request, $session ) = $this->doApiRequestWithToken( $params, $session );
+               } catch ( UsageException $e ) {
+                       $this->assertContains( 'The file you submitted was empty', $e->getMessage() );
+                       $exception = true;
+               }
+               $this->assertTrue( $exception );
+
+               // clean up
+               $this->deleteFileByFilename( $fileName );
+               unlink( $filePath );
+       }
+
+
+       /**
+        * @depends testLogin
+        */
+       public function testUploadSameFileName( $session ) { 
+               global $wgUser;
+               $wgUser = self::$users['uploader']->user;
+
+               $extension = 'png';
+               $mimeType = 'image/png';
+
+               $randomImageGenerator = new RandomImageGenerator();
+               $filePaths = $randomImageGenerator->writeImages( 2, $extension, dirname( wfTempDir() ) );
+               // we'll reuse this filename
+               $fileName = basename( $filePaths[0] ); 
+
+               // clear any other files with the same name
+               $this->deleteFileByFileName( $fileName );
+
+               // we reuse these params
+               $params = array(
+                       'action' => 'upload',
+                       'filename' => $fileName,
+                       'file' => 'dummy content',
+                       'comment' => 'dummy comment',
+                       'text'  => "This is the page text for $fileName",
+               );
+
+               // first upload .... should succeed
+               
+               if (! $this->fakeUploadFile( 'file', $fileName, $mimeType, $filePaths[0] ) ) {
+                       $this->markTestIncomplete( "Couldn't upload file!\n" );
+               }
+
+               $exception = false;
+               try {
+                       list( $result, $request, $session ) = $this->doApiRequestWithToken( $params, $session );
+               } catch ( UsageException $e ) {
+                       $exception = true;
+               }
+               $this->assertTrue( isset( $result['upload'] ) );
+               $this->assertEquals( 'Success', $result['upload']['result'] );
+               $this->assertFalse( $exception );
+
+               // second upload with the same name (but different content) 
+
+               if (! $this->fakeUploadFile( 'file', $fileName, $mimeType, $filePaths[1] ) ) {
+                       $this->markTestIncomplete( "Couldn't upload file!\n" );
+               }
+
+               $exception = false;
+               try {
+                       list( $result, $request, $session ) = $this->doApiRequestWithToken( $params, $session );
+               } catch ( UsageException $e ) {
+                       $exception = true;
+               }
+               $this->assertTrue( isset( $result['upload'] ) );
+               $this->assertEquals( 'Warning', $result['upload']['result'] );
+               $this->assertTrue( isset( $result['upload']['warnings'] ) );
+               $this->assertTrue( isset( $result['upload']['warnings']['exists'] ) );
+               $this->assertFalse( $exception );
+
+               // clean up
+               $this->deleteFileByFilename( $fileName );
+               unlink( $filePaths[0] );
+               unlink( $filePaths[1] );
+       }
+
+
+       /**
+        * @depends testLogin
+        */
+       public function testUploadSameContent( $session ) { 
+               global $wgUser;
+               $wgUser = self::$users['uploader']->user;
+
+               $extension = 'png';
+               $mimeType = 'image/png';
+
+               $randomImageGenerator = new RandomImageGenerator();
+               $filePaths = $randomImageGenerator->writeImages( 1, $extension, dirname( wfTempDir() ) );
+               $fileNames[0] = basename( $filePaths[0] ); 
+               $fileNames[1] = "SameContentAs" . $fileNames[0];
+
+               // clear any other files with the same name or content
+               $this->deleteFileByContent( $filePaths[0] );
+               $this->deleteFileByFileName( $fileNames[0] );
+               $this->deleteFileByFileName( $fileNames[1] );
+
+               // first upload .... should succeed
+               
+               $params = array(
+                       'action' => 'upload',
+                       'filename' => $fileNames[0],
+                       'file' => 'dummy content',
+                       'comment' => 'dummy comment',
+                       'text'  => "This is the page text for " . $fileNames[0],
+               );
+               
+               if (! $this->fakeUploadFile( 'file', $fileNames[0], $mimeType, $filePaths[0] ) ) {
+                       $this->markTestIncomplete( "Couldn't upload file!\n" );
+               }
+
+               $exception = false;
+               try {
+                       list( $result, $request, $session ) = $this->doApiRequestWithToken( $params, $session );
+               } catch ( UsageException $e ) {
+                       $exception = true;
+               }
+               $this->assertTrue( isset( $result['upload'] ) );
+               $this->assertEquals( 'Success', $result['upload']['result'] );
+               $this->assertFalse( $exception );
+
+
+               // second upload with the same content (but different name) 
+
+               if (! $this->fakeUploadFile( 'file', $fileNames[1], $mimeType, $filePaths[0] ) ) {
+                       $this->markTestIncomplete( "Couldn't upload file!\n" );
+               }
+       
+               $params = array(
+                       'action' => 'upload',
+                       'filename' => $fileNames[1],
+                       'file' => 'dummy content',
+                       'comment' => 'dummy comment',
+                       'text'  => "This is the page text for " . $fileNames[1],
+               );
+
+               $exception = false;
+               try {
+                       list( $result, $request, $session ) = $this->doApiRequestWithToken( $params, $session );
+               } catch ( UsageException $e ) {
+                       $exception = true;
+               }
+               $this->assertTrue( isset( $result['upload'] ) );
+               $this->assertEquals( 'Warning', $result['upload']['result'] );
+               $this->assertTrue( isset( $result['upload']['warnings'] ) );
+               $this->assertTrue( isset( $result['upload']['warnings']['duplicate'] ) );
+               $this->assertFalse( $exception );
+
+               // clean up
+               $this->deleteFileByFilename( $fileNames[0] );
+               $this->deleteFileByFilename( $fileNames[1] );
+               unlink( $filePaths[0] );
+       }
+
+
+       /**
+        * @depends testLogin
+        */
+       public function testUploadStash( $session ) { 
+               global $wgUser;
+               $wgUser = self::$users['uploader']->user;
+
+               $extension = 'png';
+               $mimeType = 'image/png';
+
+               $randomImageGenerator = new RandomImageGenerator();
+               $filePaths = $randomImageGenerator->writeImages( 1, $extension, dirname( wfTempDir() ) );
+               $filePath = $filePaths[0];
+               $fileName = basename( $filePath ); 
+
+               $this->deleteFileByFileName( $fileName );
+               $this->deleteFileByContent( $filePath );
+
+               if (! $this->fakeUploadFile( 'file', $fileName, $mimeType, $filePath ) ) {
+                       $this->markTestIncomplete( "Couldn't upload file!\n" );
+               }
+
+               $params = array(
+                       'action' => 'upload',
+                       'stash' => 1,
+                       'filename' => $fileName,
+                       'file' => 'dummy content',
+                       'comment' => 'dummy comment',
+                       'text'  => "This is the page text for $fileName",
+               );
+
+               $exception = false;
+               try {
+                       list( $result, $request, $session ) = $this->doApiRequestWithToken( $params, $session );
+               } catch ( UsageException $e ) {
+                       $exception = true;
+               }
+               $this->assertFalse( $exception );
+               $this->assertTrue( isset( $result['upload'] ) );
+               $this->assertEquals( 'Success', $result['upload']['result'] );
+               $this->assertTrue( isset( $result['upload']['sessionkey'] ) );
+               $sessionkey = $result['upload']['sessionkey'];
+               
+               // it should be visible from Special:UploadStash 
+               // XXX ...but how to test this, with a fake WebRequest with the session?
+
+               // now we should try to release the file from stash
+               $params = array(
+                       'action' => 'upload',
+                       'sessionkey' => $sessionkey,
+                       'filename' => $fileName,        
+                       'comment' => 'dummy comment',
+                       'text'  => "This is the page text for $fileName, altered",
+               );
+
+               $this->clearFakeUploads();
+               $exception = false;
+               try {
+                       list( $result, $request, $session ) = $this->doApiRequestWithToken( $params, $session );
+               } catch ( UsageException $e ) {
+                       $exception = true;
+               }
+               $this->assertTrue( isset( $result['upload'] ) );
+               $this->assertEquals( 'Success', $result['upload']['result'] );
+               $this->assertFalse( $exception );
+
+               // clean up
+               $this->deleteFileByFilename( $fileName );
+               unlink( $filePath );
+       }
+
+
+
+       /**
+        * Helper function -- remove files and associated articles by Title
+        * @param {Title} title to be removed
+        */
+       public function deleteFileByTitle( $title ) {
+               if ( $title->exists() ) { 
+                       $file = wfFindFile( $title, array( 'ignoreRedirect' => true ) );
+                       $noOldArchive = ""; // yes this really needs to be set this way
+                       $comment = "removing for test";
+                       $restrictDeletedVersions = false;
+                       $status = FileDeleteForm::doDelete( $title, $file, $noOldArchive, $comment, $restrictDeletedVersions );
+                       if ( !$status->isGood() ) {
+                               return false;
+                       }
+                       $article = new Article( $title );
+                       $article->doDeleteArticle( "removing for test" );
+
+                       // see if it now doesn't exist; reload  
+                       $title = Title::newFromText( $fileName, NS_FILE );
+               }
+               return ! ( $title && is_a( $title, 'Title' ) && $title->exists() );
+       }
+
+       /**
+        * Helper function -- remove files and associated articles with a particular filename 
+        * @param {String} filename to be removed
+        */
+       public function deleteFileByFileName( $fileName ) {
+               return $this->deleteFileByTitle( Title::newFromText( $fileName, NS_FILE ) );
+       }
+
+
+       /**
+        * Helper function -- given a file on the filesystem, find matching content in the db (and associated articles) and remove them.
+        * @param {String} path to file on the filesystem
+        */
+       public function deleteFileByContent( $filePath ) {
+               $hash = File::sha1Base36( $filePath );
+               $dupes = RepoGroup::singleton()->findBySha1( $hash );
+               $success = true;
+               foreach ( $dupes as $key => $dupe ) {
+                       $success &= $this->deleteFileByTitle( $dupe->getTitle() );
+               }
+               return $success;
+       }
+
+       /** 
+        * Fake an upload by dumping the file into temp space, and adding info to $_FILES.
+        * (This is what PHP would normally do).
+        * @param {String}: fieldname - name this would have in the upload form 
+        * @param {String}: fileName - name to title this 
+        * @param {String}: mime type
+        * @param {String}: filePath - path where to find file contents
+        */
+       function fakeUploadFile( $fieldName, $fileName, $type, $filePath ) {
+               $tmpName = tempnam( wfTempDir(), "" );
+               if ( !file_exists( $filePath ) ) {
+                       throw new Exception( "$filePath doesn't exist!" );
+               };
+
+               if ( !copy( $filePath, $tmpName ) ) { 
+                       throw new Exception( "couldn't copy $filePath to $tmpName" );
+               }
+
+               clearstatcache();
+               $size = filesize( $tmpName );
+               if ( $size === false ) {
+                       throw new Exception( "couldn't stat $tmpName" );
+               }
+
+               $_FILES[ $fieldName ] = array(
+                       'name'          => $fileName,
+                       'type'          => $type,
+                       'tmp_name'      => $tmpName,
+                       'size'          => $size,
+                       'error'         => null
+               );
+
+               return true;
+
+       }
+
+       /**
+        * Remove traces of previous fake uploads
+        */
+       function clearFakeUploads() {
+               $_FILES = array();
+       }
+
+
+}
+
diff --git a/maintenance/tests/phpunit/includes/api/RandomImageGenerator.php b/maintenance/tests/phpunit/includes/api/RandomImageGenerator.php
new file mode 100644 (file)
index 0000000..45d93ef
--- /dev/null
@@ -0,0 +1,289 @@
+<?php
+
+/* 
+ * RandomImageGenerator -- does what it says on the tin.
+ * Requires Imagick, the ImageMagick library for PHP, or the command line equivalent (usually 'convert').
+ *
+ * Because MediaWiki tests the uniqueness of media upload content, and filenames, it is sometimes useful to generate
+ * files that are guaranteed (or at least very likely) to be unique in both those ways.
+ * This generates a number of filenames with random names and random content (colored circles) 
+ *
+ * It is also useful to have fresh content because our tests currently run in a "destructive" mode, and don't create a fresh new wiki for each 
+ * test run.
+ * Consequently, if we just had a few static files we kept re-uploading, we'd get lots of warnings about matching content or filenames, 
+ * and even if we deleted those files, we'd get warnings about archived files.
+ *
+ * This can also be used with a cronjob to generate random files all the time -- I use it to have a constant, never ending supply when I'm
+ * testing interactively.
+ * 
+ * @file
+ * @author Neil Kandalgaonkar <neilk@wikimedia.org>
+ */
+
+/**
+ * RandomImageGenerator: does what it says on the tin.
+ * Can fetch a random image, or also write a number of them to disk with random filenames.
+ */
+class RandomImageGenerator {
+
+       private $dictionaryFile;
+       private $minWidth = 400;
+       private $maxWidth = 800;
+       private $minHeight = 400;
+       private $maxHeight = 800;
+       private $circlesToDraw = 5;
+       private $imageWriteMethod;
+       
+       public function __construct( $options ) {
+               global $wgUseImageMagick, $wgImageMagickConvertCommand;
+               foreach ( array( 'dictionaryFile', 'minWidth', 'minHeight', 'maxHeight', 'circlesToDraw' ) as $property ) {
+                       if ( isset( $options[$property] ) ) {
+                               $this->$property = $options[$property];
+                       }
+               }
+
+               // find the dictionary file, to generate random names
+               if ( !isset( $this->dictionaryFile ) ) {
+                       foreach ( array( '/usr/share/dict/words', '/usr/dict/words' ) as $dictionaryFile ) {
+                               if ( is_file( $dictionaryFile ) and is_readable( $dictionaryFile ) ) {
+                                       $this->dictionaryFile = $dictionaryFile;
+                                       break;
+                               }
+                       }
+               }
+               if ( !isset( $this->dictionaryFile ) ) {
+                       throw new Exception( "RandomImageGenerator: dictionary file not found or not specified properly" );
+               }
+
+               // figure out how to write images
+               if ( class_exists( 'Imagick' ) ) {
+                       $this->imageWriteMethod = 'writeImageWithApi';
+               } elseif ( $wgUseImageMagick && $wgImageMagickConvertCommand && is_executable( $wgImageMagickConvertCommand ) ) {
+                       $this->imageWriteMethod = 'writeImageWithCommandLine';
+               } else {
+                       throw new Exception( "RandomImageGenerator: could not find a suitable method to write images" );
+               }                       
+       }
+
+       /**
+        * Writes random images with random filenames to disk in the directory you specify, or current working directory
+        * 
+        * @param {Integer} number of filenames to write
+        * @param {String} format, optional, must be understood by ImageMagick, such as 'jpg' or 'gif'
+        * @param {String} directory, optional (will default to current working directory)
+        * @return {Array} filenames we just wrote
+        */
+       function writeImages( $number, $format = 'jpg', $dir = null ) {
+               $filenames = $this->getRandomFilenames( $number, $format, $dir );
+               foreach( $filenames as $filename ) {
+                       $this->{$this->imageWriteMethod}( $this->getImageSpec(), $format, $filename );
+               }
+               return $filenames;
+       }
+
+       /** 
+        * Return a number of randomly-generated filenames
+        * Each filename uses two words randomly drawn from the dictionary, like elephantine_spatula.jpg
+        *
+        * @param {Integer} number of filenames to generate
+        * @param {String} extension, optional, defaults to 'jpg'
+        * @param {String} directory, optional, defaults to current working directory
+        * @return {Array} of filenames
+        */
+       private function getRandomFilenames( $number, $extension = 'jpg', $dir = null ) {
+               if ( is_null( $dir ) ) {
+                       $dir = getcwd();
+               }
+               $filenames = array();
+               foreach( $this->getRandomWordPairs( $number ) as $pair ) {
+                       $basename = $pair[0] . '_' . $pair[1];
+                       if ( !is_null( $extension ) ) {
+                               $basename .= '.' . $extension;
+                       }
+                       $basename = preg_replace( '/\s+/', '', $basename );
+                       $filenames[] = "$dir/$basename";
+               }
+       
+               return $filenames;
+               
+       }
+
+
+       /**
+        * Generate data representing an image of random size (within limits), 
+        * consisting of randomly colored and sized circles against a random background color
+        * (This data is used in the writeImage* methods).
+        * @return {Mixed} 
+        */
+       public function getImageSpec() { 
+               $spec = array();
+               
+               $spec['width'] = mt_rand( $this->minWidth, $this->maxWidth ); 
+               $spec['height'] = mt_rand( $this->minHeight, $this->maxHeight ); 
+               $spec['fill'] = $this->getRandomColor();
+
+               $diagonalLength = sqrt( pow( $spec['width'], 2 ) + pow( $spec['height'], 2 ) );
+
+               $draws = array();
+               for ( $i = 0; $i <= $this->circlesToDraw; $i++ ) {
+                       $radius = mt_rand( 0, $diagonalLength / 4 );
+                       $originX = mt_rand( -1 * $radius, $spec['width'] + $radius );
+                       $originY = mt_rand( -1 * $radius, $spec['height'] + $radius );
+                       $perimeterX = $originX + $radius;
+                       $perimeterY = $originY + $radius;
+
+                       $draw = array();
+                       $draw['fill'] = $this->getRandomColor();
+                       $draw['circle'] = array( 
+                               'originX' => $originX, 
+                               'originY' => $originY, 
+                               'perimeterX' => $perimeterX, 
+                               'perimeterY' => $perimeterY 
+                       );
+                       $draws[] = $draw;
+                       
+               }
+
+               $spec['draws'] = $draws;
+
+               return $spec;
+       }
+
+
+       /**
+        * Based on an image specification, write such an image to disk, using Imagick PHP extension
+        * @param $spec: spec describing background and circles to draw
+        * @param $format: file format to write
+        * @param $filename: filename to write to
+        */
+       public function writeImageWithApi( $spec, $format, $filename ) { 
+               $image = new Imagick();
+               $image->newImage( $spec['width'], $spec['height'], new ImagickPixel( $spec['fill'] ) );
+
+               foreach ( $spec['draws'] as $drawSpec ) {
+                       $draw = new ImagickDraw(); 
+                       $draw->setFillColor( $drawSpec['fill'] );
+                       $circle = $drawSpec['circle'];
+                       $draw->circle( $circle['originX'], $circle['originY'], $circle['perimeterX'], $circle['perimeterY'] );
+                       $image->drawImage( $draw );
+               }
+
+               $image->setImageFormat( $format );
+               $image->writeImage( $filename );
+       }
+
+
+       /**
+        * Based on an image specification, write such an image to disk, using the command line ImageMagick program ('convert').
+        *
+        * Sample command line:
+        *    $ convert -size 100x60 xc:rgb(90,87,45)  \
+         *     -draw 'fill rgb(12,34,56)   circle 41,39 44,57' \
+         *      -draw 'fill rgb(99,123,231) circle 59,39 56,57' \
+         *      -draw 'fill rgb(240,12,32)  circle 50,21 50,3'  filename.png
+        *
+        * @param $spec: spec describing background and circles to draw
+        * @param $format: file format to write (unused by this method but kept so it has the same signature as writeImageWithApi)
+        * @param $filename: filename to write to
+        */
+       public function writeImageWithCommandLine( $spec, $format, $filename ) {
+               global $wgImageMagickConvertCommand;
+               $args = array();
+               $args[] = "-size " . wfEscapeShellArg( $spec['width'] . 'x' . $spec['height'] );
+               $args[] = wfEscapeShellArg( "xc:" . $spec['fill'] );
+               foreach( $spec['draws'] as $draw ) {
+                       $fill = $draw['fill'];
+                       $originX = $draw['circle']['originX'];
+                       $originY = $draw['circle']['originY'];
+                       $perimeterX = $draw['circle']['perimeterX'];
+                       $perimeterY = $draw['circle']['perimeterY'];
+                       $drawCommand = "fill $fill  circle $originX,$originY $perimeterX,$perimeterY";
+                       $args[] = '-draw ' . wfEscapeShellArg( $drawCommand );
+               }
+               $args[] = $filename;
+
+               $command = wfEscapeShellArg( $wgImageMagickConvertCommand ) . " " . implode( " ", $args );
+               $output = wfShellExec( $command, $retval );
+               return ( $retval === 0 );
+       }
+
+               
+
+       /**
+        * Generate a string of random colors for ImageMagick, like "rgb(12, 37, 98)"
+        * 
+        * @return {String}
+        */
+       public function getRandomColor() {
+               $components = array();
+               for ($i = 0; $i <= 2; $i++ ) {
+                       $components[] = mt_rand( 0, 255 );
+               }
+               return 'rgb(' . join(', ', $components) . ')';
+       }
+
+       /** 
+        * Get an array of random pairs of random words, like array( array( 'foo', 'bar' ), array( 'quux', 'baz' ) );
+        *
+        * @param {Integer} number of pairs
+        * @return {Array} of two-element arrays 
+        */
+       private function getRandomWordPairs( $number ) { 
+               $lines = $this->getRandomLines( $number * 2 );
+               // construct pairs of words
+               $pairs = array();
+               $count = count( $lines );
+               for( $i = 0; $i < $count; $i += 2 )  {
+                       $pairs[] = array( $lines[$i], $lines[$i+1] );
+               }
+               return $pairs;
+       }
+
+       
+       /**
+        * Return N random lines from a file
+        * 
+        * Will throw exception if the file could not be read or if it had fewer lines than requested.
+        * 
+        * @param {Integer} number of lines desired
+        * @string {String} path to file 
+        * @return {Array} of exactly n elements, drawn randomly from lines the file
+        */
+       private function getRandomLines( $number_desired ) { 
+               $filepath = $this->dictionaryFile;
+
+               // initialize array of lines
+               $lines = array();
+               for ( $i = 0; $i < $number_desired; $i++ ) {
+                       $lines[] = null;
+               }
+
+               /*
+                * This algorithm obtains N random lines from a file in one single pass. It does this by replacing elements of 
+                * a fixed-size array of lines, less and less frequently as it reads the file.
+                */
+               $fh = fopen( $filepath, "r" );
+               if ( !$fh ) {
+                        throw new Exception( "couldn't open $filepath" );
+               }
+               $line_number = 0;
+               $max_index = $number_desired - 1;
+               while( !feof( $fh ) ) { 
+                       $line = fgets( $fh );
+                       if ( $line !== false ) {
+                               $line_number++;  
+                               $line = trim( $line ); 
+                               if ( mt_rand( 0, $line_number ) <= $max_index ) { 
+                                       $lines[ mt_rand( 0, $max_index ) ] = $line;
+                               }
+                       }
+               }
+               fclose( $fh );
+               if ( $line_number < $number_desired ) {
+                       throw new Exception( "not enough lines in $filepath" );
+               }
+               
+               return $lines;
+       }
+
+}
diff --git a/maintenance/tests/phpunit/includes/api/generateRandomImages.php b/maintenance/tests/phpunit/includes/api/generateRandomImages.php
new file mode 100644 (file)
index 0000000..ee91e2d
--- /dev/null
@@ -0,0 +1,25 @@
+<?php
+
+require("RandomImageGenerator.php");
+
+$getOptSpec = array( 
+       'dictionaryFile::',
+       'minWidth::',
+       'maxWidth::',
+       'minHeight::',
+       'maxHeight::',
+       'circlesToDraw::',
+
+       'number::',
+       'format::'
+);
+$options = getopt( null, $getOptSpec );
+
+$format = isset( $options['format'] ) ? $options['format'] : 'jpg';
+unset( $options['format'] );
+
+$number = isset( $options['number'] ) ? int( $options['number'] ) : 10;
+unset( $options['number'] );
+
+$randomImageGenerator = new RandomImageGenerator( $options );
+$randomImageGenerator->writeImages( $number, $format );