diff options
Diffstat (limited to 'includes/upload')
-rw-r--r-- | includes/upload/UploadBase.php | 314 | ||||
-rw-r--r-- | includes/upload/UploadFromFile.php | 33 | ||||
-rw-r--r-- | includes/upload/UploadFromStash.php | 130 | ||||
-rw-r--r-- | includes/upload/UploadFromUrl.php | 31 | ||||
-rw-r--r-- | includes/upload/UploadStash.php | 615 |
5 files changed, 817 insertions, 306 deletions
diff --git a/includes/upload/UploadBase.php b/includes/upload/UploadBase.php index 546b9db8..a97edbc7 100644 --- a/includes/upload/UploadBase.php +++ b/includes/upload/UploadBase.php @@ -18,14 +18,16 @@ abstract class UploadBase { protected $mDesiredDestName, $mDestName, $mRemoveTempFile, $mSourceType; protected $mTitle = false, $mTitleError = 0; protected $mFilteredName, $mFinalExtension; - protected $mLocalFile; + protected $mLocalFile, $mFileSize, $mFileProps; + protected $mBlackListedExtensions; + protected $mJavaDetected; const SUCCESS = 0; const OK = 0; const EMPTY_FILE = 3; const MIN_LENGTH_PARTNAME = 4; const ILLEGAL_FILENAME = 5; - const OVERWRITE_EXISTING_FILE = 7; # Not used anymore; handled by verifyPermissions() + const OVERWRITE_EXISTING_FILE = 7; # Not used anymore; handled by verifyTitlePermissions() const FILETYPE_MISSING = 8; const FILETYPE_BADTYPE = 9; const VERIFICATION_ERROR = 10; @@ -34,13 +36,7 @@ abstract class UploadBase { const UPLOAD_VERIFICATION_ERROR = 11; const HOOK_ABORTED = 11; const FILE_TOO_LARGE = 12; - - const SESSION_VERSION = 2; - const SESSION_KEYNAME = 'wsUploadData'; - - static public function getSessionKeyname() { - return self::SESSION_KEYNAME; - } + const WINDOWS_NONASCII_FILENAME = 13; public function getVerificationErrorCode( $error ) { $code_to_status = array(self::EMPTY_FILE => 'empty-file', @@ -52,6 +48,7 @@ abstract class UploadBase { self::OVERWRITE_EXISTING_FILE => 'overwrite', self::VERIFICATION_ERROR => 'verification-error', self::HOOK_ABORTED => 'hookaborted', + self::WINDOWS_NONASCII_FILENAME => 'windows-nonascii-filename', ); if( isset( $code_to_status[$error] ) ) { return $code_to_status[$error]; @@ -66,21 +63,21 @@ abstract class UploadBase { */ public static function isEnabled() { global $wgEnableUploads; + if ( !$wgEnableUploads ) { return false; } # Check php's file_uploads setting - if( !wfIniGetBool( 'file_uploads' ) ) { - return false; - } - return true; + return wfIsHipHop() || wfIniGetBool( 'file_uploads' ); } /** * Returns true if the user can use this upload module or else a string * identifying the missing permission. * Can be overriden by subclasses. + * + * @param $user User */ public static function isAllowed( $user ) { foreach ( array( 'upload', 'edit' ) as $permission ) { @@ -96,6 +93,9 @@ abstract class UploadBase { /** * Create a form of UploadBase depending on wpSourceType and initializes it + * + * @param $request WebRequest + * @param $type */ public static function createFromRequest( &$request, $type = null ) { $type = $type ? $type : $request->getVal( 'wpSourceType', 'File' ); @@ -144,6 +144,14 @@ abstract class UploadBase { public function __construct() {} /** + * Returns the upload type. Should be overridden by child classes + * + * @since 1.18 + * @return string + */ + public function getSourceType() { return null; } + + /** * Initialize the path information * @param $name string the desired destination name * @param $tempPath string the temporary path @@ -200,6 +208,19 @@ abstract class UploadBase { } /** + * Finish appending to the Repo file + * + * @param $toAppendPath String: path to the Repo file that will be appended to. + * @return Status Status + */ + protected function appendFinish( $toAppendPath ) { + $repo = RepoGroup::singleton()->getLocalRepo(); + $status = $repo->appendFinish( $toAppendPath ); + return $status; + } + + + /** * @param $srcPath String: the source path * @return the real path if it was a virtual URL */ @@ -226,11 +247,11 @@ abstract class UploadBase { /** * Honor $wgMaxUploadSize */ - global $wgMaxUploadSize; - if( $this->mFileSize > $wgMaxUploadSize ) { - return array( + $maxSize = self::getMaxUploadSize( $this->getSourceType() ); + if( $this->mFileSize > $maxSize ) { + return array( 'status' => self::FILE_TOO_LARGE, - 'max' => $wgMaxUploadSize, + 'max' => $maxSize, ); } @@ -279,6 +300,9 @@ abstract class UploadBase { } if ( $this->mTitleError == self::FILETYPE_BADTYPE ) { $result['finalExt'] = $this->mFinalExtension; + if ( count( $this->mBlackListedExtensions ) ) { + $result['blacklistedExt'] = $this->mBlackListedExtensions; + } } return $result; } @@ -297,15 +321,16 @@ abstract class UploadBase { global $wgVerifyMimeType; if ( $wgVerifyMimeType ) { wfDebug ( "\n\nmime: <$mime> extension: <{$this->mFinalExtension}>\n\n"); - if ( !$this->verifyExtension( $mime, $this->mFinalExtension ) ) { - return array( 'filetype-mime-mismatch' ); - } - global $wgMimeTypeBlacklist; if ( $this->checkFileExtension( $mime, $wgMimeTypeBlacklist ) ) { return array( 'filetype-badmime', $mime ); } + # XXX: Missing extension will be caught by validateName() via getTitle() + if ( $this->mFinalExtension != '' && !$this->verifyExtension( $mime, $this->mFinalExtension ) ) { + return array( 'filetype-mime-mismatch', $this->mFinalExtension, $mime ); + } + # Check IE type $fp = fopen( $this->mTempPath, 'rb' ); $chunk = fread( $fp, 256 ); @@ -330,12 +355,12 @@ abstract class UploadBase { * @return mixed true of the file is verified, array otherwise. */ protected function verifyFile() { + global $wgAllowJavaUploads; # get the title, even though we are doing nothing with it, because - # we need to populate mFinalExtension + # we need to populate mFinalExtension $this->getTitle(); - + $this->mFileProps = File::getPropsFromPath( $this->mTempPath, $this->mFinalExtension ); - $this->checkMacBinary(); # check mime type, if desired $mime = $this->mFileProps[ 'file-mime' ]; @@ -354,9 +379,25 @@ abstract class UploadBase { } } - /** - * Scan the uploaded file for viruses - */ + # Check for Java applets, which if uploaded can bypass cross-site + # restrictions. + if ( !$wgAllowJavaUploads ) { + $this->mJavaDetected = false; + $zipStatus = ZipDirectoryReader::read( $this->mTempPath, + array( $this, 'zipEntryCallback' ) ); + if ( !$zipStatus->isOK() ) { + $errors = $zipStatus->getErrorsArray(); + $error = reset( $errors ); + if ( $error[0] !== 'zip-wrong-format' ) { + return $error; + } + } + if ( $this->mJavaDetected ) { + return array( 'uploadjava' ); + } + } + + # Scan the uploaded file for viruses $virus = $this->detectVirus( $this->mTempPath ); if ( $virus ) { return array( 'uploadvirus', $virus ); @@ -381,17 +422,51 @@ abstract class UploadBase { } /** + * Callback for ZipDirectoryReader to detect Java class files. + */ + function zipEntryCallback( $entry ) { + $names = array( $entry['name'] ); + + // If there is a null character, cut off the name at it, because JDK's + // ZIP_GetEntry() uses strcmp() if the name hashes match. If a file name + // were constructed which had ".class\0" followed by a string chosen to + // make the hash collide with the truncated name, that file could be + // returned in response to a request for the .class file. + $nullPos = strpos( $entry['name'], "\000" ); + if ( $nullPos !== false ) { + $names[] = substr( $entry['name'], 0, $nullPos ); + } + + // If there is a trailing slash in the file name, we have to strip it, + // because that's what ZIP_GetEntry() does. + if ( preg_grep( '!\.class/?$!', $names ) ) { + $this->mJavaDetected = true; + } + } + + /** + * Alias for verifyTitlePermissions. The function was originally 'verifyPermissions' + * but that suggests it's checking the user, when it's really checking the title + user combination. + * @param $user User object to verify the permissions against + * @return mixed An array as returned by getUserPermissionsErrors or true + * in case the user has proper permissions. + */ + public function verifyPermissions( $user ) { + return $this->verifyTitlePermissions( $user ); + } + + /** * Check whether the user can edit, upload and create the image. This * checks only against the current title; if it returns errors, it may * very well be that another title will not give errors. Therefore * isAllowed() should be called as well for generic is-user-blocked or * can-user-upload checking. * - * @param $user the User object to verify the permissions against + * @param $user User object to verify the permissions against * @return mixed An array as returned by getUserPermissionsErrors or true * in case the user has proper permissions. */ - public function verifyPermissions( $user ) { + public function verifyTitlePermissions( $user ) { /** * If the image is protected, non-sysop users won't be able * to modify it by uploading a new revision. @@ -412,12 +487,12 @@ abstract class UploadBase { $permErrors = array_merge( $permErrors, wfArrayDiff2( $permErrorsCreate, $permErrors ) ); return $permErrors; } - + $overwriteError = $this->checkOverwrite( $user ); if ( $overwriteError !== true ) { return array( $overwriteError ); } - + return true; } @@ -427,11 +502,12 @@ abstract class UploadBase { * @return Array of warnings */ public function checkWarnings() { + global $wgLang; + $warnings = array(); $localFile = $this->getLocalFile(); $filename = $localFile->getName(); - $n = strrpos( $filename, '.' ); /** * Check whether the resulting filename is different from the desired one, @@ -448,7 +524,8 @@ abstract class UploadBase { global $wgCheckFileExtensions, $wgFileExtensions; if ( $wgCheckFileExtensions ) { if ( !$this->checkFileExtension( $this->mFinalExtension, $wgFileExtensions ) ) { - $warnings['filetype-unwanted-type'] = $this->mFinalExtension; + $warnings['filetype-unwanted-type'] = array( $this->mFinalExtension, + $wgLang->commaList( $wgFileExtensions ), count( $wgFileExtensions ) ); } } @@ -493,24 +570,26 @@ abstract class UploadBase { * Really perform the upload. Stores the file in the local repo, watches * if necessary and runs the UploadComplete hook. * - * @return mixed Status indicating the whether the upload succeeded. + * @param $user User + * + * @return Status indicating the whether the upload succeeded. */ public function performUpload( $comment, $pageText, $watch, $user ) { - $status = $this->getLocalFile()->upload( - $this->mTempPath, - $comment, + $status = $this->getLocalFile()->upload( + $this->mTempPath, + $comment, $pageText, File::DELETE_SOURCE, - $this->mFileProps, - false, - $user + $this->mFileProps, + false, + $user ); if( $status->isGood() ) { if ( $watch ) { $user->addWatch( $this->getLocalFile()->getTitle() ); } - + wfRunHooks( 'UploadComplete', array( &$this ) ); } @@ -527,13 +606,23 @@ abstract class UploadBase { if ( $this->mTitle !== false ) { return $this->mTitle; } + + /* Assume that if a user specified File:Something.jpg, this is an error + * and that the namespace prefix needs to be stripped of. + */ + $title = Title::newFromText( $this->mDesiredDestName ); + if ( $title && $title->getNamespace() == NS_FILE ) { + $this->mFilteredName = $title->getDBkey(); + } else { + $this->mFilteredName = $this->mDesiredDestName; + } /** * Chop off any directories in the given filename. Then * filter out illegal characters, and try to make a legible name * out of it. We'll strip some silently that Title would die on. */ - $this->mFilteredName = wfStripIllegalFilenameChars( $this->mDesiredDestName ); + $this->mFilteredName = wfStripIllegalFilenameChars( $this->mFilteredName ); /* Normalize to title form before we do any further processing */ $nt = Title::makeTitleSafe( NS_FILE, $this->mFilteredName ); if( is_null( $nt ) ) { @@ -552,20 +641,48 @@ abstract class UploadBase { $this->mFinalExtension = trim( $ext[count( $ext ) - 1] ); } else { $this->mFinalExtension = ''; + + # No extension, try guessing one + $magic = MimeMagic::singleton(); + $mime = $magic->guessMimeType( $this->mTempPath ); + if ( $mime !== 'unknown/unknown' ) { + # Get a space separated list of extensions + $extList = $magic->getExtensionsForType( $mime ); + if ( $extList ) { + # Set the extension to the canonical extension + $this->mFinalExtension = strtok( $extList, ' ' ); + + # Fix up the other variables + $this->mFilteredName .= ".{$this->mFinalExtension}"; + $nt = Title::makeTitleSafe( NS_FILE, $this->mFilteredName ); + $ext = array( $this->mFinalExtension ); + } + } + } /* Don't allow users to override the blacklist (check file extension) */ global $wgCheckFileExtensions, $wgStrictFileExtensions; global $wgFileExtensions, $wgFileBlacklist; + + $blackListedExtensions = $this->checkFileExtensionList( $ext, $wgFileBlacklist ); + if ( $this->mFinalExtension == '' ) { $this->mTitleError = self::FILETYPE_MISSING; return $this->mTitle = null; - } elseif ( $this->checkFileExtensionList( $ext, $wgFileBlacklist ) || + } elseif ( $blackListedExtensions || ( $wgCheckFileExtensions && $wgStrictFileExtensions && - !$this->checkFileExtension( $this->mFinalExtension, $wgFileExtensions ) ) ) { + !$this->checkFileExtensionList( $ext, $wgFileExtensions ) ) ) { + $this->mBlackListedExtensions = $blackListedExtensions; $this->mTitleError = self::FILETYPE_BADTYPE; return $this->mTitle = null; } + + // Windows may be broken with special characters, see bug XXX + if ( wfIsWindows() && !preg_match( '/^[\x0-\x7f]*$/', $nt->getText() ) ) { + $this->mTitleError = self::WINDOWS_NONASCII_FILENAME; + return $this->mTitle = null; + } # If there was more than one "extension", reassemble the base # filename to prevent bogus complaints about length @@ -585,6 +702,8 @@ abstract class UploadBase { /** * Return the local file and initializes if necessary. + * + * @return LocalFile */ public function getLocalFile() { if( is_null( $this->mLocalFile ) ) { @@ -619,31 +738,40 @@ abstract class UploadBase { * 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 + * into the database. + * This method returns the file object, which also has a 'fileKey' property which can be passed through a form or * API request to find this stashed file again. * - * @param $key String: (optional) the session key used to find the file info again. If not supplied, a key will be autogenerated. - * @return File: stashed file + * @param $key String: (optional) the file key used to find the file info again. If not supplied, a key will be autogenerated. + * @return UploadStashFile stashed file */ - public function stashSessionFile( $key = null ) { + public function stashFile( $key = null ) { + // was stashSessionFile $stash = RepoGroup::singleton()->getLocalRepo()->getUploadStash(); - $data = array( - 'mFileProps' => $this->mFileProps - ); - $file = $stash->stashFile( $this->mTempPath, $data, $key ); + + $file = $stash->stashFile( $this->mTempPath, $this->getSourceType(), $key ); $this->mLocalFile = $file; return $file; } /** - * Stash a file in a temporary directory, returning a key which can be used to find the file again. See stashSessionFile(). + * Stash a file in a temporary directory, returning a key which can be used to find the file again. See stashFile(). * - * @param $key String: (optional) the session key used to find the file info again. If not supplied, a key will be autogenerated. - * @return String: session key + * @param $key String: (optional) the file key used to find the file info again. If not supplied, a key will be autogenerated. + * @return String: file key + */ + public function stashFileGetKey( $key = null ) { + return $this->stashFile( $key )->getFileKey(); + } + + /** + * alias for stashFileGetKey, for backwards compatibility + * + * @param $key String: (optional) the file key used to find the file info again. If not supplied, a key will be autogenerated. + * @return String: file key */ public function stashSession( $key = null ) { - return $this->stashSessionFile( $key )->getSessionKey(); + return $this->stashFileGetKey( $key ); } /** @@ -689,19 +817,14 @@ abstract class UploadBase { /** * Perform case-insensitive match against a list of file extensions. - * Returns true if any of the extensions are in the list. + * Returns an array of matching extensions. * * @param $ext Array * @param $list Array * @return Boolean */ public static function checkFileExtensionList( $ext, $list ) { - foreach( $ext as $e ) { - if( in_array( strtolower( $e ), $list ) ) { - return true; - } - } - return false; + return array_intersect( array_map( 'strtolower', $ext ), $list ); } /** @@ -788,7 +911,7 @@ abstract class UploadBase { $chunk = trim( $chunk ); - # FIXME: convert from UTF-16 if necessarry! + # @todo FIXME: Convert from UTF-16 if necessarry! wfDebug( __METHOD__ . ": checking for embedded scripts and HTML stuff\n" ); # check for HTML doctype @@ -828,6 +951,7 @@ abstract class UploadBase { foreach( $tags as $tag ) { if( false !== strpos( $chunk, $tag ) ) { + wfDebug( __METHOD__ . ": found something that may make it be mistaken for html: $tag\n" ); return true; } } @@ -841,16 +965,19 @@ abstract class UploadBase { # look for script-types if( preg_match( '!type\s*=\s*[\'"]?\s*(?:\w*/)?(?:ecma|java)!sim', $chunk ) ) { + wfDebug( __METHOD__ . ": found script types\n" ); return true; } # look for html-style script-urls if( preg_match( '!(?:href|src|data)\s*=\s*[\'"]?\s*(?:ecma|java)script:!sim', $chunk ) ) { + wfDebug( __METHOD__ . ": found html-style script urls\n" ); return true; } # look for css-style script-urls if( preg_match( '!url\s*\(\s*[\'"]?\s*(?:ecma|java)script:!sim', $chunk ) ) { + wfDebug( __METHOD__ . ": found css-style script urls\n" ); return true; } @@ -989,33 +1116,11 @@ abstract class UploadBase { } /** - * Check if the temporary file is MacBinary-encoded, as some uploads - * from Internet Explorer on Mac OS Classic and Mac OS X will be. - * If so, the data fork will be extracted to a second temporary file, - * which will then be checked for validity and either kept or discarded. - */ - private function checkMacBinary() { - $macbin = new MacBinary( $this->mTempPath ); - if( $macbin->isValid() ) { - $dataFile = tempnam( wfTempDir(), 'WikiMacBinary' ); - $dataHandle = fopen( $dataFile, 'wb' ); - - wfDebug( __METHOD__ . ": Extracting MacBinary data fork to $dataFile\n" ); - $macbin->extractData( $dataHandle ); - - $this->mTempPath = $dataFile; - $this->mFileSize = $macbin->dataForkLength(); - - // We'll have to manually remove the new file if it's not kept. - $this->mRemoveTempFile = true; - } - $macbin->close(); - } - - /** * Check if there's an overwrite conflict and, if so, if restrictions * forbid this user from performing the upload. * + * @param $user User + * * @return mixed true on success, array on failure */ private function checkOverwrite( $user ) { @@ -1072,7 +1177,7 @@ abstract class UploadBase { * - File exists with normalized extension * - The file looks like a thumbnail and the original exists * - * @param $file The File object to check + * @param $file File The File object to check * @return mixed False if the file does not exists, else an array */ public static function getExistsWarning( $file ) { @@ -1170,9 +1275,9 @@ abstract class UploadBase { */ public static function getFilenamePrefixBlacklist() { $blacklist = array(); - $message = wfMsgForContent( 'filename-prefix-blacklist' ); - if( $message && !( wfEmptyMsg( 'filename-prefix-blacklist', $message ) || $message == '-' ) ) { - $lines = explode( "\n", $message ); + $message = wfMessage( 'filename-prefix-blacklist' )->inContentLanguage(); + if( !$message->isDisabled() ) { + $lines = explode( "\n", $message->plain() ); foreach( $lines as $line ) { // Remove comment lines $comment = substr( trim( $line ), 0, 1 ); @@ -1191,18 +1296,18 @@ abstract class UploadBase { } /** - * Gets image info about the file just uploaded. + * 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 + * 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. + * with the appropriate format, presumably. * * @param $result ApiResult: * @return Array: image info */ public function getImageInfo( $result ) { $file = $this->getLocalFile(); - // TODO This cries out for refactoring. We really want to say $file->getAllInfo(); here. + // TODO This cries out for refactoring. We really want to say $file->getAllInfo(); here. // Perhaps "info" methods should be moved into files, and the API should just wrap them in queries. if ( $file instanceof UploadStashFile ) { $imParam = ApiQueryStashImageInfo::getPropertyNames(); @@ -1220,4 +1325,19 @@ abstract class UploadBase { unset( $code['status'] ); return Status::newFatal( $this->getVerificationErrorCode( $code ), $error ); } + + public static function getMaxUploadSize( $forType = null ) { + global $wgMaxUploadSize; + + if ( is_array( $wgMaxUploadSize ) ) { + if ( !is_null( $forType ) && isset( $wgMaxUploadSize[$forType] ) ) { + return $wgMaxUploadSize[$forType]; + } else { + return $wgMaxUploadSize['*']; + } + } else { + return intval( $wgMaxUploadSize ); + } + + } } diff --git a/includes/upload/UploadFromFile.php b/includes/upload/UploadFromFile.php index e67ec191..c2ab6467 100644 --- a/includes/upload/UploadFromFile.php +++ b/includes/upload/UploadFromFile.php @@ -8,8 +8,15 @@ */ class UploadFromFile extends UploadBase { + + /** + * @var WebRequestUpload + */ protected $mUpload = null; + /** + * @param $request WebRequest + */ function initializeFromRequest( &$request ) { $upload = $request->getUpload( 'wpUploadFile' ); $desiredDestName = $request->getText( 'wpDestFile' ); @@ -18,31 +25,47 @@ class UploadFromFile extends UploadBase { return $this->initialize( $desiredDestName, $upload ); } - + /** * Initialize from a filename and a WebRequestUpload + * @param $name + * @param $webRequestUpload */ function initialize( $name, $webRequestUpload ) { $this->mUpload = $webRequestUpload; return $this->initializePathInfo( $name, $this->mUpload->getTempName(), $this->mUpload->getSize() ); } + + /** + * @param $request + * @return bool + */ static function isValidRequest( $request ) { # Allow all requests, even if no file is present, so that an error # because a post_max_size or upload_max_filesize overflow return true; } - + + /** + * @return string + */ + public function getSourceType() { + return 'file'; + } + + /** + * @return array + */ public function verifyUpload() { # Check for a post_max_size or upload_max_size overflow, so that a # proper error can be shown to the user if ( is_null( $this->mTempPath ) || $this->isEmptyFile() ) { if ( $this->mUpload->isIniSizeOverflow() ) { - global $wgMaxUploadSize; return array( 'status' => UploadBase::FILE_TOO_LARGE, 'max' => min( - $wgMaxUploadSize, + self::getMaxUploadSize( $this->getSourceType() ), wfShorthandToInteger( ini_get( 'upload_max_filesize' ) ), wfShorthandToInteger( ini_get( 'post_max_size' ) ) ), @@ -60,6 +83,4 @@ class UploadFromFile extends UploadBase { public function getFileTempname() { return $this->mUpload->getTempname(); } - - } diff --git a/includes/upload/UploadFromStash.php b/includes/upload/UploadFromStash.php index 156781e9..feb14a87 100644 --- a/includes/upload/UploadFromStash.php +++ b/includes/upload/UploadFromStash.php @@ -8,66 +8,109 @@ */ class UploadFromStash extends UploadBase { - public static function isValidSessionKey( $key, $sessionData ) { - return !empty( $key ) && - is_array( $sessionData ) && - isset( $sessionData[$key] ) && - isset( $sessionData[$key]['version'] ) && - $sessionData[$key]['version'] == UploadBase::SESSION_VERSION; + protected $mFileKey, $mVirtualTempPath, $mFileProps, $mSourceType; + + // an instance of UploadStash + private $stash; + + //LocalFile repo + private $repo; + + public function __construct( $user = false, $stash = false, $repo = false ) { + // user object. sometimes this won't exist, as when running from cron. + $this->user = $user; + + if( $repo ) { + $this->repo = $repo; + } else { + $this->repo = RepoGroup::singleton()->getLocalRepo(); + } + + if( $stash ) { + $this->stash = $stash; + } else { + wfDebug( __METHOD__ . " creating new UploadStash instance for " . $user->getId() . "\n" ); + $this->stash = new UploadStash( $this->repo, $this->user ); + } + + return true; + } + + public static function isValidKey( $key ) { + // this is checked in more detail in UploadStash + return preg_match( UploadStash::KEY_FORMAT_REGEX, $key ); } + /** + * @param $request WebRequest + * + * @return Boolean + */ public static function isValidRequest( $request ) { - $sessionData = $request->getSessionData( UploadBase::SESSION_KEYNAME ); - return self::isValidSessionKey( - $request->getText( 'wpSessionKey' ), - $sessionData - ); + return self::isValidKey( $request->getText( 'wpFileKey' ) || $request->getText( 'wpSessionKey' ) ); } - public function initialize( $name, $sessionKey, $sessionData ) { - /** - * Confirming a temporarily stashed upload. - * We don't want path names to be forged, so we keep - * them in the session on the server and just give - * an opaque key to the user agent. - */ - - $this->initializePathInfo( $name, - $this->getRealPath ( $sessionData['mTempPath'] ), - $sessionData['mFileSize'], - false - ); - - $this->mSessionKey = $sessionKey; - $this->mVirtualTempPath = $sessionData['mTempPath']; - $this->mFileProps = $sessionData['mFileProps']; + public function initialize( $key, $name = 'upload_file' ) { + /** + * Confirming a temporarily stashed upload. + * We don't want path names to be forged, so we keep + * them in the session on the server and just give + * an opaque key to the user agent. + */ + $metadata = $this->stash->getMetadata( $key ); + $this->initializePathInfo( $name, + $this->getRealPath ( $metadata['us_path'] ), + $metadata['us_size'], + false + ); + + $this->mFileKey = $key; + $this->mVirtualTempPath = $metadata['us_path']; + $this->mFileProps = $this->stash->getFileProps( $key ); + $this->mSourceType = $metadata['us_source_type']; } + /** + * @param $request WebRequest + */ public function initializeFromRequest( &$request ) { - $sessionKey = $request->getText( 'wpSessionKey' ); - $sessionData = $request->getSessionData( UploadBase::SESSION_KEYNAME ); + $fileKey = $request->getText( 'wpFileKey' ) || $request->getText( 'wpSessionKey' ); $desiredDestName = $request->getText( 'wpDestFile' ); - if( !$desiredDestName ) - $desiredDestName = $request->getText( 'wpUploadFile' ); - return $this->initialize( $desiredDestName, $sessionKey, $sessionData[$sessionKey] ); + if( !$desiredDestName ) { + $desiredDestName = $request->getText( 'wpUploadFile' ) || $request->getText( 'filename' ); + } + return $this->initialize( $fileKey, $desiredDestName ); + } + + public function getSourceType() { + return $this->mSourceType; } /** * File has been previously verified so no need to do so again. + * + * @return bool */ protected function verifyFile() { return true; } - /** * There is no need to stash the image twice */ + public function stashFile( $key = null ) { + if ( !empty( $this->mLocalFile ) ) { + return $this->mLocalFile; + } + return parent::stashFile( $key ); + } + + /** + * Alias for stashFile + */ public function stashSession( $key = null ) { - if ( !empty( $this->mSessionKey ) ) - return $this->mSessionKey; - return parent::stashSession(); + return $this->stashFile( $key ); } /** @@ -75,9 +118,16 @@ class UploadFromStash extends UploadBase { * @return success */ public function unsaveUploadedFile() { - $repo = RepoGroup::singleton()->getLocalRepo(); - $success = $repo->freeTemp( $this->mVirtualTempPath ); - return $success; + return $this->stash->removeFile( $this->mFileKey ); + } + + /** + * Perform the upload, then remove the database record afterward. + */ + public function performUpload( $comment, $pageText, $watch, $user ) { + $rv = parent::performUpload( $comment, $pageText, $watch, $user ); + $this->unsaveUploadedFile(); + return $rv; } }
\ No newline at end of file diff --git a/includes/upload/UploadFromUrl.php b/includes/upload/UploadFromUrl.php index c28fd7da..8178988f 100644 --- a/includes/upload/UploadFromUrl.php +++ b/includes/upload/UploadFromUrl.php @@ -12,9 +12,13 @@ class UploadFromUrl extends UploadBase { protected $mAsync, $mUrl; protected $mIgnoreWarnings = true; + protected $mTempPath; + /** * Checks if the user is allowed to use the upload-by-URL feature. If the * user is allowed, pass on permissions checking to the parent. + * + * @param $user User */ public static function isAllowed( $user ) { if ( !$user->isAllowed( 'upload_by_url' ) ) @@ -45,6 +49,9 @@ class UploadFromUrl extends UploadBase { $this->mUrl = $url; $this->mAsync = $wgAllowAsyncCopyUploads ? $async : false; + if ( $async ) { + throw new MWException( 'Asynchronous copy uploads are no longer possible as of r81612.' ); + } $tempPath = $this->mAsync ? null : $this->makeTemporaryFile(); # File size and removeTempFile will be filled in later @@ -53,7 +60,7 @@ class UploadFromUrl extends UploadBase { /** * Entry point for SpecialUpload - * @param $request Object: WebRequest object + * @param $request WebRequest object */ public function initializeFromRequest( &$request ) { $desiredDestName = $request->getText( 'wpDestFile' ); @@ -61,13 +68,13 @@ class UploadFromUrl extends UploadBase { $desiredDestName = $request->getText( 'wpUploadFileURL' ); return $this->initialize( $desiredDestName, - $request->getVal( 'wpUploadFileURL' ), + trim( $request->getVal( 'wpUploadFileURL' ) ), false ); } /** - * @param $request Object: WebRequest object + * @param $request WebRequest object */ public static function isValidRequest( $request ) { global $wgUser; @@ -78,6 +85,7 @@ class UploadFromUrl extends UploadBase { && $wgUser->isAllowed( 'upload_by_url' ); } + public function getSourceType() { return 'url'; } public function fetchFile() { if ( !Http::isValidURI( $this->mUrl ) ) { @@ -137,7 +145,9 @@ class UploadFromUrl extends UploadBase { $this->mRemoveTempFile = true; $this->mFileSize = 0; - $req = MWHttpRequest::factory( $this->mUrl ); + $req = MWHttpRequest::factory( $this->mUrl, array( + 'followRedirects' => true + ) ); $req->setCallback( array( $this, 'saveTempFileChunk' ) ); $status = $req->execute(); @@ -184,11 +194,11 @@ class UploadFromUrl extends UploadBase { * Wrapper around the parent function in order to defer checking protection * until we are sure that the file can actually be uploaded */ - public function verifyPermissions( $user ) { + public function verifyTitlePermissions( $user ) { if ( $this->mAsync ) { return true; } - return parent::verifyPermissions( $user ); + return parent::verifyTitlePermissions( $user ); } /** @@ -207,7 +217,13 @@ class UploadFromUrl extends UploadBase { return parent::performUpload( $comment, $pageText, $watch, $user ); } - + /** + * @param $comment + * @param $pageText + * @param $watch + * @param $user User + * @return + */ protected function insertJob( $comment, $pageText, $watch, $user ) { $sessionKey = $this->stashSession(); $job = new UploadFromUrlJob( $this->getTitle(), array( @@ -226,5 +242,4 @@ class UploadFromUrl extends UploadBase { return $sessionKey; } - } diff --git a/includes/upload/UploadStash.php b/includes/upload/UploadStash.php index 1765925d..9304ce5f 100644 --- a/includes/upload/UploadStash.php +++ b/includes/upload/UploadStash.php @@ -1,110 +1,190 @@ <?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 applications to find said files later, as long as the db table 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. + * We accomplish this using a database table, with ownership checking as you might expect. See SpecialUploadStash, which + * implements a web interface to some files stored this way. * + * UploadStash represents the entire stash of temporary files. + * UploadStashFile is a filestore for the actual physical disk files. + * UploadFromStash extends UploadBase, and represents a single stashed file as it is moved from the stash to the regular file repository */ class UploadStash { // Format of the key for files -- has to be suitable as a filename itself (e.g. ab12cd34ef.jpg) - const KEY_FORMAT_REGEX = '/^[\w-]+\.\w*$/'; + const KEY_FORMAT_REGEX = '/^[\w-\.]+\.\w*$/'; - // repository that this uses to store temp files - // public because we sometimes need to get a LocalFile within the same repo. - public $repo; - - // array of initialized objects obtained from session (lazily initialized upon getFile()) - private $files = array(); + // When a given stashed file can't be loaded, wait for the slaves to catch up. If they're more than MAX_LAG + // behind, throw an exception instead. (at what point is broken better than slow?) + const MAX_LAG = 30; - // TODO: Once UploadBase starts using this, switch to use these constants rather than UploadBase::SESSION* - // const SESSION_VERSION = 2; - // const SESSION_KEYNAME = 'wsUploadData'; + // Age of the repository in hours. That is, after how long will files be assumed abandoned and deleted? + const REPO_AGE = 6; /** - * Represents the session which contains temporarily stored files. - * Designed to be compatible with the session stashing code in UploadBase (should replace it eventually) + * repository that this uses to store temp files + * public because we sometimes need to get a LocalFile within the same repo. * - * @param $repo FileRepo: optional -- repo in which to store files. Will choose LocalRepo if not supplied. + * @var LocalRepo */ - public function __construct( $repo ) { + public $repo; + + // array of initialized repo objects + protected $files = array(); + + // cache of the file metadata that's stored in the database + protected $fileMetadata = array(); + + // fileprops cache + protected $fileProps = array(); + + // current user + protected $user, $userId, $isLoggedIn; + /** + * Represents a temporary filestore, with metadata in the database. + * Designed to be compatible with the session stashing code in UploadBase (should replace it eventually) + * + * @param $repo FileRepo + */ + public function __construct( $repo, $user = null ) { // this might change based on wiki's configuration. $this->repo = $repo; - if ( ! isset( $_SESSION ) ) { - throw new UploadStashNotAvailableException( 'no session variable' ); + // if a user was passed, use it. otherwise, attempt to use the global. + // this keeps FileRepo from breaking when it creates an UploadStash object + if ( $user ) { + $this->user = $user; + } else { + global $wgUser; + $this->user = $wgUser; } - if ( !isset( $_SESSION[UploadBase::SESSION_KEYNAME] ) ) { - $_SESSION[UploadBase::SESSION_KEYNAME] = array(); + if ( is_object( $this->user ) ) { + $this->userId = $this->user->getId(); + $this->isLoggedIn = $this->user->isLoggedIn(); } - } /** * 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 $key Integer: key + * @param $key String: key under which file information is stored + * @param $noAuth Boolean (optional) Don't check authentication. Used by maintenance scripts. * @throws UploadStashFileNotFoundException - * @throws UploadStashBadVersionException + * @throws UploadStashNotLoggedInException + * @throws UploadStashWrongOwnerException + * @throws UploadStashBadPathException * @return UploadStashFile */ - public function getFile( $key ) { + public function getFile( $key, $noAuth = false ) { + 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 stash" ); + } + + if ( !$noAuth ) { + if ( !$this->isLoggedIn ) { + throw new UploadStashNotLoggedInException( __METHOD__ . ' No user is logged in, files must belong to users' ); + } + } + + $dbr = $this->repo->getSlaveDb(); + + if ( !isset( $this->fileMetadata[$key] ) ) { + // try this first. if it fails to find the row, check for lag, wait, try again. if its still missing, throw an exception. + // this more complex solution keeps things moving for page loads with many requests + // (ie. validating image ownership) when replag is high + if ( !$this->fetchFileMetadata( $key ) ) { + $lag = $dbr->getLag(); + if ( $lag > 0 && $lag <= self::MAX_LAG ) { + // if there's not too much replication lag, just wait for the slave to catch up to our last insert. + sleep( ceil( $lag ) ); + } elseif ( $lag > self::MAX_LAG ) { + // that's a lot of lag to introduce into the middle of the UI. + throw new UploadStashMaxLagExceededException( + 'Couldn\'t load stashed file metadata, and replication lag is above threshold: (MAX_LAG=' . self::MAX_LAG . ')' + ); + } + + // now that the waiting has happened, try again + $this->fetchFileMetadata( $key ); } - $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 ); + if ( !isset( $this->fileMetadata[$key] ) ) { + throw new UploadStashFileNotFoundException( "key '$key' not found in stash" ); } - - // 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 ); - if ( $file->getSize === 0 ) { - throw new UploadStashZeroLengthFileException( "File is zero length" ); + + // create $this->files[$key] + $this->initFile( $key ); + + // fetch fileprops + $path = $this->fileMetadata[$key]['us_path']; + if ( $this->repo->isVirtualUrl( $path ) ) { + $path = $this->repo->resolveVirtualUrl( $path ); } - $this->files[$key] = $file; + $this->fileProps[$key] = File::getPropsFromPath( $path ); + } + + if ( ! $this->files[$key]->exists() ) { + wfDebug( __METHOD__ . " tried to get file at $key, but it doesn't exist\n" ); + throw new UploadStashBadPathException( "path doesn't exist" ); + } + if ( !$noAuth ) { + if ( $this->fileMetadata[$key]['us_user'] != $this->userId ) { + throw new UploadStashWrongOwnerException( "This file ($key) doesn't belong to the current user." ); + } } + 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. + * Getter for file metadata. + * + * @param key String: key under which file information is stored + * @return Array + */ + public function getMetadata ( $key ) { + $this->getFile( $key ); + return $this->fileMetadata[$key]; + } + + /** + * Getter for fileProps + * + * @param key String: key under which file information is stored + * @return Array + */ + public function getFileProps ( $key ) { + $this->getFile( $key ); + return $this->fileProps[$key]; + } + + /** + * Stash a file in a temp directory and record that we did this in the database, along with other metadata. * * @param $path String: path to file you want stashed - * @param $data Array: optional, other data you want associated with the file. Do not use 'mTempPath', 'mFileProps', 'mFileSize', or 'version' as keys here - * @param $key String: optional, unique key for this file in this session. Used for directory hashing when storing, otherwise not important + * @param $sourceType String: the type of upload that generated this file (currently, I believe, 'file' or null) + * @param $key String: optional, unique key for this file. Used for directory hashing when storing, otherwise not important * @throws UploadStashBadPathException * @throws UploadStashFileException + * @throws UploadStashNotLoggedInException * @return UploadStashFile: file, or null on failure */ - public function stashFile( $path, $data = array(), $key = null ) { + public function stashFile( $path, $sourceType = null, $key = null ) { if ( ! file_exists( $path ) ) { - wfDebug( "UploadStash: tried to stash file at '$path', but it doesn't exist\n" ); + wfDebug( __METHOD__ . " tried to stash file at '$path', but it doesn't exist\n" ); throw new UploadStashBadPathException( "path doesn't exist" ); } - $fileProps = File::getPropsFromPath( $path ); + $fileProps = File::getPropsFromPath( $path ); + wfDebug( __METHOD__ . " stashing file at '$path'\n" ); // 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. @@ -115,65 +195,251 @@ class UploadStash { throw new UploadStashFileException( "couldn't rename $path to have a better extension at $pathWithGoodExtension" ); } $path = $pathWithGoodExtension; - } + } - // 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 no key was supplied, make one. a mysql insertid would be totally reasonable here, except + // that some users of this function might expect to supply the key instead of using the generated one. if ( is_null( $key ) ) { - $key = $fileProps['sha1'] . "." . $extension; + // some things that when combined will make a suitably unique key. + // see: http://www.jwz.org/doc/mid.html + list ($usec, $sec) = explode( ' ', microtime() ); + $usec = substr($usec, 2); + $key = wfBaseConvert( $sec . $usec, 10, 36 ) . '.' . + wfBaseConvert( mt_rand(), 10, 36 ) . '.'. + $this->userId . '.' . + $extension; } + $this->fileProps[$key] = $fileProps; + if ( ! preg_match( self::KEY_FORMAT_REGEX, $key ) ) { throw new UploadStashBadPathException( "key '$key' is not in a proper format" ); - } + } + wfDebug( __METHOD__ . " key for '$path': $key\n" ); // if not already in a temporary area, put it there - $status = $this->repo->storeTemp( basename( $path ), $path ); + $storeStatus = $this->repo->storeTemp( basename( $path ), $path ); - if( ! $status->isOK() ) { + if ( ! $storeStatus->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 + // This is a bit lame, as we may have more info in the $storeStatus 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() ); + // $storeStatus->value just contains the virtual URL (if anything) which is probably useless to the caller + $error = $storeStatus->getErrorsArray(); + $error = reset( $error ); if ( ! count( $error ) ) { - $error = reset( $status->getWarningsArray() ); + $error = $storeStatus->getWarningsArray(); + $error = reset( $error ); 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 + $stashPath = $storeStatus->value; + + // fetch the current user ID + if ( !$this->isLoggedIn ) { + throw new UploadStashNotLoggedInException( __METHOD__ . ' No user is logged in, files must belong to users' ); + } + + // insert the file metadata into the db. + wfDebug( __METHOD__ . " inserting $stashPath under $key\n" ); + $dbw = $this->repo->getMasterDb(); + + // select happens on the master so this can all be in a transaction, which + // avoids a race condition that's likely with multiple people uploading from the same + // set of files + $dbw->begin(); + // first, check to see if it's already there. + $row = $dbw->selectRow( + 'uploadstash', + 'us_user, us_timestamp', + array( 'us_key' => $key ), + __METHOD__ + ); + + // The current user can't have this key if: + // - the key is owned by someone else and + // - the age of the key is less than REPO_AGE + if ( is_object( $row ) ) { + if ( $row->us_user != $this->userId && + $row->wfTimestamp( TS_UNIX, $row->us_timestamp ) > time() - UploadStash::REPO_AGE * 3600 + ) { + $dbw->rollback(); + throw new UploadStashWrongOwnerException( "Attempting to upload a duplicate of a file that someone else has stashed" ); + } + } + + $this->fileMetadata[$key] = array( + 'us_user' => $this->userId, + 'us_key' => $key, + 'us_orig_path' => $path, + 'us_path' => $stashPath, + 'us_size' => $fileProps['size'], + 'us_sha1' => $fileProps['sha1'], + 'us_mime' => $fileProps['mime'], + 'us_media_type' => $fileProps['media_type'], + 'us_image_width' => $fileProps['width'], + 'us_image_height' => $fileProps['height'], + 'us_image_bits' => $fileProps['bits'], + 'us_source_type' => $sourceType, + 'us_timestamp' => $dbw->timestamp(), + 'us_status' => 'finished' ); - // 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.) - wfDebug( __METHOD__ . " storing under $key\n" ); - $_SESSION[UploadBase::SESSION_KEYNAME][$key] = array_merge( $data, $requiredData ); - + // if a row exists but previous checks on it passed, let the current user take over this key. + $dbw->replace( + 'uploadstash', + 'us_key', + $this->fileMetadata[$key], + __METHOD__ + ); + $dbw->commit(); + + // store the insertid in the class variable so immediate retrieval (possibly laggy) isn't necesary. + $this->fileMetadata[$key]['us_id'] = $dbw->insertId(); + + # create the UploadStashFile object for this file. + $this->initFile( $key ); + return $this->getFile( $key ); } /** + * Remove all files from the stash. + * Does not clean up files in the repo, just the record of them. + * + * @throws UploadStashNotLoggedInException + * @return boolean: success + */ + public function clear() { + if ( !$this->isLoggedIn ) { + throw new UploadStashNotLoggedInException( __METHOD__ . ' No user is logged in, files must belong to users' ); + } + + wfDebug( __METHOD__ . " clearing all rows for user $userId\n" ); + $dbw = $this->repo->getMasterDb(); + $dbw->delete( + 'uploadstash', + array( 'us_user' => $this->userId ), + __METHOD__ + ); + + # destroy objects. + $this->files = array(); + $this->fileMetadata = array(); + + return true; + } + + /** + * Remove a particular file from the stash. Also removes it from the repo. + * + * @throws UploadStashNotLoggedInException + * @throws UploadStashWrongOwnerException + * @return boolean: success + */ + public function removeFile( $key ) { + if ( !$this->isLoggedIn ) { + throw new UploadStashNotLoggedInException( __METHOD__ . ' No user is logged in, files must belong to users' ); + } + + $dbw = $this->repo->getMasterDb(); + + // this is a cheap query. it runs on the master so that this function still works when there's lag. + // it won't be called all that often. + $row = $dbw->selectRow( + 'uploadstash', + 'us_user', + array( 'us_key' => $key ), + __METHOD__ + ); + + if( !$row ) { + throw new UploadStashNoSuchKeyException( "No such key ($key), cannot remove" ); + } + + if ( $row->us_user != $this->userId ) { + throw new UploadStashWrongOwnerException( "Can't delete: the file ($key) doesn't belong to this user." ); + } + + return $this->removeFileNoAuth( $key ); + } + + + /** + * Remove a file (see removeFile), but doesn't check ownership first. + * + * @return boolean: success + */ + public function removeFileNoAuth( $key ) { + wfDebug( __METHOD__ . " clearing row $key\n" ); + + $dbw = $this->repo->getMasterDb(); + + // this gets its own transaction since it's called serially by the cleanupUploadStash maintenance script + $dbw->begin(); + $dbw->delete( + 'uploadstash', + array( 'us_key' => $key ), + __METHOD__ + ); + $dbw->commit(); + + // TODO: look into UnregisteredLocalFile and find out why the rv here is sometimes wrong (false when file was removed) + // for now, ignore. + $this->files[$key]->remove(); + + unset( $this->files[$key] ); + unset( $this->fileMetadata[$key] ); + + return true; + } + + /** + * List all files in the stash. + * + * @throws UploadStashNotLoggedInException + * @return Array + */ + public function listFiles() { + if ( !$this->isLoggedIn ) { + throw new UploadStashNotLoggedInException( __METHOD__ . ' No user is logged in, files must belong to users' ); + } + + $dbr = $this->repo->getSlaveDb(); + $res = $dbr->select( + 'uploadstash', + 'us_key', + array( 'us_key' => $key ), + __METHOD__ + ); + + if ( !is_object( $res ) || $res->numRows() == 0 ) { + // nothing to do. + return false; + } + + // finish the read before starting writes. + $keys = array(); + foreach ( $res as $row ) { + array_push( $keys, $row->us_key ); + } + + return $keys; + } + + /** * 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 + * Since these files are constructed from php tempnames they may not start off * with an extension. - * XXX this is somewhat redundant with the checks that ApiUpload.php does with incoming + * XXX this is somewhat redundant with the checks that ApiUpload.php does with incoming * uploads versus the desired filename. Maybe we can get that passed to us... */ - public static function getExtensionForPath( $path ) { + public static function getExtensionForPath( $path ) { // Does this have an extension? $n = strrpos( $path, '.' ); $extension = null; @@ -184,8 +450,8 @@ class UploadStash { $magic = MimeMagic::singleton(); $mimeType = $magic->guessMimeType( $path ); $extensions = explode( ' ', MimeMagic::singleton()->getExtensionsForType( $mimeType ) ); - if ( count( $extensions ) ) { - $extension = $extensions[0]; + if ( count( $extensions ) ) { + $extension = $extensions[0]; } } @@ -196,52 +462,103 @@ class UploadStash { return File::normalizeExtension( $extension ); } + /** + * Helper function: do the actual database query to fetch file metadata. + * + * @param $key String: key + * @return boolean + */ + protected function fetchFileMetadata( $key ) { + // populate $fileMetadata[$key] + $dbr = $this->repo->getSlaveDb(); + $row = $dbr->selectRow( + 'uploadstash', + '*', + array( 'us_key' => $key ), + __METHOD__ + ); + + if ( !is_object( $row ) ) { + // key wasn't present in the database. this will happen sometimes. + return false; + } + + $this->fileMetadata[$key] = array( + 'us_user' => $row->us_user, + 'us_key' => $row->us_key, + 'us_orig_path' => $row->us_orig_path, + 'us_path' => $row->us_path, + 'us_size' => $row->us_size, + 'us_sha1' => $row->us_sha1, + 'us_mime' => $row->us_mime, + 'us_media_type' => $row->us_media_type, + 'us_image_width' => $row->us_image_width, + 'us_image_height' => $row->us_image_height, + 'us_image_bits' => $row->us_image_bits, + 'us_source_type' => $row->us_source_type, + 'us_timestamp' => $row->us_timestamp, + 'us_status' => $row->us_status + ); + + return true; + } + + /** + * Helper function: Initialize the UploadStashFile for a given file. + * + * @param $path String: path to file + * @param $key String: key under which to store the object + * @throws UploadStashZeroLengthFileException + * @return bool + */ + protected function initFile( $key ) { + $file = new UploadStashFile( $this->repo, $this->fileMetadata[$key]['us_path'], $key ); + if ( $file->getSize() === 0 ) { + throw new UploadStashZeroLengthFileException( "File is zero length" ); + } + $this->files[$key] = $file; + return true; + } } class UploadStashFile extends UnregisteredLocalFile { - private $sessionStash; - private $sessionKey; - private $sessionData; + private $fileKey; private $urlName; + protected $url; /** * 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 $stash UploadStash: useful for obtaining config, stashing transformed files - * @param $repo FileRepo: repository where we should find the path + * @param $repo FSRepo: repository where we should find the path * @param $path String: path to file * @param $key String: key to store the path and any stashed data under - * @param $data String: 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; + public function __construct( $repo, $path, $key ) { + $this->fileKey = $key; // resolve mwrepo:// urls if ( $repo->isVirtualUrl( $path ) ) { - $path = $repo->resolveVirtualUrl( $path ); - } + $path = $repo->resolveVirtualUrl( $path ); + } else { - // 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 ) ) { - wfDebug( "UploadStash: tried to construct an UploadStashFile from a file that should already exist at '$path', but path is not valid\n" ); - throw new UploadStashBadPathException( 'path is not valid' ); - } + // 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 ) ) { + wfDebug( "UploadStash: tried to construct an UploadStashFile from a file that should already exist at '$path', but path is not valid\n" ); + throw new UploadStashBadPathException( 'path is not valid' ); + } - // check if path exists! and is a plain file. - if ( ! $repo->fileExists( $path, FileRepo::FILES_ONLY ) ) { - wfDebug( "UploadStash: tried to construct an UploadStashFile from a file that should already exist at '$path', but path is not found\n" ); - throw new UploadStashFileNotFoundException( 'cannot find path, or not a plain file' ); + // check if path exists! and is a plain file. + if ( ! $repo->fileExists( $path, FileRepo::FILES_ONLY ) ) { + wfDebug( "UploadStash: tried to construct an UploadStashFile from a file that should already exist at '$path', but path is not found\n" ); + throw new UploadStashFileNotFoundException( 'cannot find path, or not a plain file' ); + } } - - parent::__construct( false, $repo, $path, false ); $this->name = basename( $this->path ); @@ -261,13 +578,13 @@ class UploadStashFile extends UnregisteredLocalFile { /** * 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 + * The actual argument is the result of thumbName although we seem to have * buggy code elsewhere that expects a boolean 'suffix' * * @param $thumbName String: 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 ) { + public function getThumbPath( $thumbName = false ) { $path = dirname( $this->path ); if ( $thumbName !== false ) { $path .= "/$thumbName"; @@ -276,71 +593,49 @@ class UploadStashFile extends UnregisteredLocalFile { } /** - * Return the file/url base name of a thumbnail with the specified parameters + * Return the file/url base name of a thumbnail with the specified parameters. + * We override this because we want to use the pretty url name instead of the + * ugly file name. * * @param $params Array: handler-specific parameters * @return String: base name for URL, like '120px-12345.jpg', or null if there is no handler */ function thumbName( $params ) { - return $this->getParamThumbName( $this->getUrlName(), $params ); - } - - - /** - * Given the name of the original, i.e. Foo.jpg, and scaling parameters, returns filename with appropriate extension - * This is abstracted from getThumbName because we also use it to calculate the thumbname the file should have on - * remote image scalers - * - * @param String $urlName: A filename, like MyMovie.ogx - * @param Array $parameters: scaling parameters, like array( 'width' => '120' ); - * @return String|null parameterized thumb name, like 120px-MyMovie.ogx.jpg, or null if no handler found - */ - function getParamThumbName( $urlName, $params ) { - if ( !$this->getHandler() ) { - return null; - } - $extension = $this->getExtension(); - list( $thumbExt, ) = $this->handler->getThumbType( $extension, $this->getMimeType(), $params ); - $thumbName = $this->getHandler()->makeParamString( $params ) . '-' . $urlName; - if ( $thumbExt != $extension ) { - $thumbName .= ".$thumbExt"; - } - return $thumbName; + return $this->generateThumbName( $this->getUrlName(), $params ); } /** * Helper function -- given a 'subpage', return the local URL e.g. /wiki/Special:UploadStash/subpage * @param {String} $subPage - * @return {String} local URL for this subpage in the Special:UploadStash space. + * @return {String} local URL for this subpage in the Special:UploadStash space. */ private function getSpecialUrl( $subPage ) { return SpecialPage::getTitleFor( 'UploadStash', $subPage )->getLocalURL(); } - - /** - * Get a URL to access the thumbnail - * This is required because the model of how files work requires that + /** + * 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) + * (that's hidden in the db) * * @param $thumbName String: 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 ) { + public function getThumbUrl( $thumbName = false ) { wfDebug( __METHOD__ . " getting for $thumbName \n" ); return $this->getSpecialUrl( 'thumb/' . $this->getUrlName() . '/' . $thumbName ); } - /** + /** * 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() { + public function getUrlName() { if ( ! $this->urlName ) { - $this->urlName = $this->sessionKey; + $this->urlName = $this->fileKey; } return $this->urlName; } @@ -359,23 +654,22 @@ class UploadStashFile extends UnregisteredLocalFile { } /** - * Parent classes use this method, for no obvious reason, to return the path (relative to wiki root, I assume). + * 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() { + 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) + * Getter for file key (the unique id by which this file's location & metadata is stored in the db) * - * @return String: session key + * @return String: file key */ - public function getSessionKey() { - return $this->sessionKey; + public function getFileKey() { + return $this->fileKey; } /** @@ -383,15 +677,26 @@ class UploadStashFile extends UnregisteredLocalFile { * @return Status: success */ public function remove() { + if ( !$this->repo->fileExists( $this->path, FileRepo::FILES_ONLY ) ) { + // Maybe the file's already been removed? This could totally happen in UploadBase. + return true; + } + return $this->repo->freeTemp( $this->path ); } + public function exists() { + return $this->repo->fileExists( $this->path, FileRepo::FILES_ONLY ); + } + } class UploadStashNotAvailableException extends MWException {}; class UploadStashFileNotFoundException extends MWException {}; class UploadStashBadPathException extends MWException {}; -class UploadStashBadVersionException extends MWException {}; class UploadStashFileException extends MWException {}; class UploadStashZeroLengthFileException extends MWException {}; - +class UploadStashNotLoggedInException extends MWException {}; +class UploadStashWrongOwnerException extends MWException {}; +class UploadStashMaxLagExceededException extends MWException {}; +class UploadStashNoSuchKeyException extends MWException {}; |