From 902995cb1d2577cc043e643fdb94f76c5c9f6c07 Mon Sep 17 00:00:00 2001 From: Neil Kandalgaonkar Date: Wed, 3 Nov 2010 04:32:41 +0000 Subject: [PATCH] core changes for UploadWizard (merged from r73549 to HEAD in branches/uploadwizard/phase3) --- includes/AutoLoader.php | 2 + includes/SpecialPage.php | 1 + includes/api/ApiQueryImageInfo.php | 62 +- includes/api/ApiUpload.php | 132 ++-- includes/filerepo/File.php | 4 +- includes/specials/SpecialUploadStash.php | 140 ++++ includes/upload/UploadBase.php | 64 +- includes/upload/UploadFromFile.php | 10 + includes/upload/UploadStash.php | 406 +++++++++++ maintenance/tests/phpunit/Makefile | 4 +- .../phpunit/includes/api/ApiUploadTest.php | 645 ++++++++++++++++++ .../includes/api/RandomImageGenerator.php | 289 ++++++++ .../includes/api/generateRandomImages.php | 25 + 13 files changed, 1683 insertions(+), 101 deletions(-) create mode 100644 includes/specials/SpecialUploadStash.php create mode 100644 includes/upload/UploadStash.php create mode 100644 maintenance/tests/phpunit/includes/api/ApiUploadTest.php create mode 100644 maintenance/tests/phpunit/includes/api/RandomImageGenerator.php create mode 100644 maintenance/tests/phpunit/includes/api/generateRandomImages.php diff --git a/includes/AutoLoader.php b/includes/AutoLoader.php index 8d7e76bfbe..4bb9d66187 100644 --- a/includes/AutoLoader.php +++ b/includes/AutoLoader.php @@ -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', diff --git a/includes/SpecialPage.php b/includes/SpecialPage.php index db267fc5ba..859865effb 100644 --- a/includes/SpecialPage.php +++ b/includes/SpecialPage.php @@ -149,6 +149,7 @@ class SpecialPage { 'MIMEsearch' => array( 'SpecialPage', 'MIMEsearch' ), 'FileDuplicateSearch' => array( 'SpecialPage', 'FileDuplicateSearch' ), 'Upload' => 'SpecialUpload', + 'UploadStash' => 'SpecialUploadStash', # Wiki data and tools 'Statistics' => 'SpecialStatistics', diff --git a/includes/api/ApiQueryImageInfo.php b/includes/api/ApiQueryImageInfo.php index 1973d93a75..de4d5f8163 100644 --- a/includes/api/ApiQueryImageInfo.php +++ b/includes/api/ApiQueryImageInfo.php @@ -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' ); } diff --git a/includes/api/ApiUpload.php b/includes/api/ApiUpload.php index f423347b7e..e7d7b9391c 100644 --- a/includes/api/ApiUpload.php +++ b/includes/api/ApiUpload.php @@ -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; diff --git a/includes/filerepo/File.php b/includes/filerepo/File.php index 7b81f176ed..9e582ad6f1 100644 --- a/includes/filerepo/File.php +++ b/includes/filerepo/File.php @@ -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 index 0000000000..e28203a89b --- /dev/null +++ b/includes/specials/SpecialUploadStash.php @@ -0,0 +1,140 @@ + '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() ); + } +} + diff --git a/includes/upload/UploadBase.php b/includes/upload/UploadBase.php index e09ac0ff04..4c2e50761e 100644 --- a/includes/upload/UploadBase.php +++ b/includes/upload/UploadBase.php @@ -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'] ); diff --git a/includes/upload/UploadFromFile.php b/includes/upload/UploadFromFile.php index 9ca9229aee..79932088a4 100644 --- a/includes/upload/UploadFromFile.php +++ b/includes/upload/UploadFromFile.php @@ -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 index 0000000000..3e4467a90c --- /dev/null +++ b/includes/upload/UploadStash.php @@ -0,0 +1,406 @@ +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 {}; + diff --git a/maintenance/tests/phpunit/Makefile b/maintenance/tests/phpunit/Makefile index e14c5b1677..9ec845e698 100644 --- a/maintenance/tests/phpunit/Makefile +++ b/maintenance/tests/phpunit/Makefile @@ -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 index 0000000000..1388dd0786 --- /dev/null +++ b/maintenance/tests/phpunit/includes/api/ApiUploadTest.php @@ -0,0 +1,645 @@ +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 index 0000000000..45d93ef9ed --- /dev/null +++ b/maintenance/tests/phpunit/includes/api/RandomImageGenerator.php @@ -0,0 +1,289 @@ + + */ + +/** + * 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 index 0000000000..ee91e2dce4 --- /dev/null +++ b/maintenance/tests/phpunit/includes/api/generateRandomImages.php @@ -0,0 +1,25 @@ +writeImages( $number, $format ); -- 2.20.1