'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',
'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',
'MIMEsearch' => array( 'SpecialPage', 'MIMEsearch' ),
'FileDuplicateSearch' => array( 'SpecialPage', 'FileDuplicateSearch' ),
'Upload' => 'SpecialUpload',
+ 'UploadStash' => 'SpecialUploadStash',
# Wiki data and tools
'Statistics' => 'SpecialStatistics',
*/
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() {
$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] ) ) {
}
}
+ /**
+ * 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
*
),
'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,
);
);
}
+
+ /**
+ * Return the API documentation for the parameters.
+ * @return {Array} parameter documentation.
+ */
public function getParamDescription() {
$p = $this->getModulePrefix();
return array(
' 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'
);
}
// 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;
* @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'] ) ) {
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() );
}
}
+
/**
* 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;
}
/**
$result['result'] = 'Success';
$result['filename'] = $file->getName();
- $result['imageinfo'] = $this->mUpload->getImageInfo( $this->getResult() );
+
return $result;
}
'ignorewarnings' => false,
'file' => null,
'url' => null,
-
'sessionkey' => null,
+ 'stash' => false,
);
global $wgAllowAsyncCopyUploads;
'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;
* @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;
$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;
}
--- /dev/null
+<?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() );
+ }
+}
+
}
/**
+ * 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.
*
}
/**
- * 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();
}
/**
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'] );
return parent::verifyUpload();
}
+
+ /**
+ * Get the path to the file underlying the upload
+ * @return String path to file
+ */
+ public function getFileTempname() {
+ return $this->mUpload->getTempname();
+ }
+
+
}
--- /dev/null
+<?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 {};
+
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
# Options:
# CONFIG_FILE Path to a PHPUnit configuration file (default: suite.xml)
# FLAGS Additional flags to pass to PHPUnit
+ # PHP Path to php
--- /dev/null
+<?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();
+ }
+
+
+}
+
--- /dev/null
+<?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;
+ }
+
+}
--- /dev/null
+<?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 );