merging latest master
[lhc/web/wiklou.git] / includes / upload / UploadFromChunks.php
index 4e3e1df..54a68af 100644 (file)
 <?php
 /**
- * @file
- * @ingroup upload
+ * Backend for uploading files from chunks.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
  *
- * First, destination checks are made, and, if ignorewarnings is not
- * checked, errors / warning is returned.
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
  *
- * 1. We return the uploadUrl.
- * 2. We then accept chunk uploads from the client.
- * 3. Return chunk id on each POSTED chunk.
- * 4. Once the client posts "done=1", the files are concatenated together.
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
  *
- * (More info at: http://firefogg.org/dev/chunk_post.html)
+ * @file
+ * @ingroup Upload
  */
-class UploadFromChunks extends UploadBase {
-
-       const INIT = 1;
-       const CHUNK = 2;
-       const DONE = 3;
-
-       protected $chunkMode; // INIT, CHUNK, DONE
-       protected $sessionKey;
-       protected $comment;
-       protected $repoPath;
-       protected $pageText;
-       protected $watch;
-
-       public $status;
-
-       // Parent class requires this function even though it is only
-       // used from SpecialUpload.php and we don't do chunked uploading
-       // from SpecialUpload -- best to raise an exception for
-       // now.
-       public function initializeFromRequest( &$request ) {
-               throw new MWException( 'not implemented' );
-       }
 
-       public function initialize( $done, $filename, $sessionKey, $path, $fileSize, $sessionData ) {
-               $this->status = Status::newGood();
+/**
+ * Implements uploading from chunks
+ *
+ * @ingroup Upload
+ * @author Michael Dale
+ */
+class UploadFromChunks extends UploadFromFile {
+       protected $mOffset, $mChunkIndex, $mFileKey, $mVirtualTempPath;
+
+       /**
+        * Setup local pointers to stash, repo and user ( similar to UploadFromStash )
+        *
+        * @param $user User
+        * @param $stash UploadStash
+        * @param $repo FileRepo
+        */
+       public function __construct( $user = false, $stash = false, $repo = false ) {
+               // user object. sometimes this won't exist, as when running from cron.
+               $this->user = $user;
 
-               $this->initializePathInfo( $filename, $path, 0, true );
-               if ( $sessionKey !== null ) {
-                       $this->initFromSessionKey( $sessionKey, $sessionData, $fileSize );
+               if( $repo ) {
+                       $this->repo = $repo;
+               } else {
+                       $this->repo = RepoGroup::singleton()->getLocalRepo();
+               }
 
-                       if ( $done ) {
-                               $this->chunkMode = self::DONE;
+               if( $stash ) {
+                       $this->stash = $stash;
+               } else {
+                       if( $user ) {
+                               wfDebug( __METHOD__ . " creating new UploadFromChunks instance for " . $user->getId() . "\n" );
                        } else {
-                               $this->mTempPath = $path;
-                               $this->chunkMode = self::CHUNK;
+                               wfDebug( __METHOD__ . " creating new UploadFromChunks instance with no user\n" );
                        }
-               } else {
-                       // session key not set, init the chunk upload system:
-                       $this->chunkMode = self::INIT;
+                       $this->stash = new UploadStash( $this->repo, $this->user );
                }
 
-               if ( $this->status->isOk()
-                       && ( $this->mDesiredDestName === null || $this->mFileSize === null ) ) {
-                       $this->status = Status::newFatal( 'chunk-init-error' );
-               }
+               return true;
        }
-
        /**
-        * Set session information for chunked uploads and allocate a unique key.
-        * @param $comment string
-        * @param $pageText string
-        * @param $watch boolean
+        * Calls the parent stashFile and updates the uploadsession table to handle "chunks" 
         *
-        * @returns string the session key for this chunked upload
+        * @return UploadStashFile stashed file
         */
-       protected function setupChunkSession( $comment, $pageText, $watch ) {
-               if ( !isset( $this->sessionKey ) ) {
-                       $this->sessionKey = $this->getSessionKey();
-               }
-               foreach ( array( 'mFilteredName', 'repoPath', 'mFileSize', 'mDesiredDestName' )
-                               as $key ) {
-                       if ( isset( $this->$key ) ) {
-                               $_SESSION['wsUploadData'][$this->sessionKey][$key] = $this->$key;
-                       }
-               }
-               if ( isset( $comment ) ) {
-                       $_SESSION['wsUploadData'][$this->sessionKey]['commment'] = $comment;
-               }
-               if ( isset( $pageText ) ) {
-                       $_SESSION['wsUploadData'][$this->sessionKey]['pageText'] = $pageText;
-               }
-               if ( isset( $watch ) ) {
-                       $_SESSION['wsUploadData'][$this->sessionKey]['watch'] = $watch;
-               }
-               $_SESSION['wsUploadData'][$this->sessionKey]['version'] = self::SESSION_VERSION;
+       public function stashFile() {
+               // Stash file is the called on creating a new chunk session: 
+               $this->mChunkIndex = 0;
+               $this->mOffset = 0;
+               // Create a local stash target
+               $this->mLocalFile = parent::stashFile();
+               // Update the initial file offset ( based on file size ) 
+               $this->mOffset = $this->mLocalFile->getSize();
+               $this->mFileKey = $this->mLocalFile->getFileKey();
 
-               return $this->sessionKey;
+               // Output a copy of this first to chunk 0 location:
+               $status = $this->outputChunk( $this->mLocalFile->getPath() );
+
+               // Update db table to reflect initial "chunk" state 
+               $this->updateChunkStatus();
+               return $this->mLocalFile;
        }
 
        /**
-        * Initialize a continuation of a chunked upload from a session key
-        * @param $sessionKey string
-        * @param $request WebRequest
-        * @param $fileSize int Size of this chunk
-        *
-        * @returns void
-        */
-       protected function initFromSessionKey( $sessionKey, $sessionData, $fileSize ) {
-               // testing against null because we don't want to cause obscure
-               // bugs when $sessionKey is full of "0"
-               $this->sessionKey = $sessionKey;
-
-               if ( isset( $sessionData[$this->sessionKey]['version'] )
-                       && $sessionData[$this->sessionKey]['version'] == self::SESSION_VERSION )
-               {
-                       foreach ( array( 'comment', 'pageText', 'watch', 'mFilteredName', 'repoPath', 'mFileSize', 'mDesiredDestName' )
-                                       as $key ) {
-                               if ( isset( $sessionData[$this->sessionKey][$key] ) ) {
-                                       $this->$key = $sessionData[$this->sessionKey][$key];
-                               }
-                       }
+        * Continue chunk uploading
+        */     
+       public function continueChunks( $name, $key, $webRequestUpload ) {
+               $this->mFileKey = $key;
+               $this->mUpload = $webRequestUpload;
+               // Get the chunk status form the db: 
+               $this->getChunkStatus();
 
-                       $this->mFileSize += $fileSize;
-               } else {
-                       $this->status = Status::newFatal( 'invalid-session-key' );
-               }
+               $metadata = $this->stash->getMetadata( $key );
+               $this->initializePathInfo( $name,
+                       $this->getRealPath( $metadata['us_path'] ),
+                       $metadata['us_size'],
+                       false
+               );
        }
 
        /**
-        * Handle a chunk of the upload.  Overrides the parent method
-        * because Chunked Uploading clients (i.e. Firefogg) require
-        * specific API responses.
-        * @see UploadBase::performUpload
+        * Append the final chunk and ready file for parent::performUpload()
+        * @return FileRepoStatus
         */
-       public function performUpload( $comment, $pageText, $watch, $user ) {
-               wfDebug( "\n\n\performUpload(chunked): comment:" . $comment . ' pageText: ' . $pageText . ' watch:' . $watch );
-               global $wgUser, $wgOut;
-
-               if ( $this->chunkMode == self::INIT ) {
-                       // firefogg expects a specific result per:
-                       // http://www.firefogg.org/dev/chunk_post.html
-
-                       // it's okay to return the token here because
-                       // a) the user must have requested the token to get here and
-                       // b) should only happen over POST
-                       // c) we need the token to validate chunks are coming from a non-xss request
-                       return Status::newGood(
-                               array( 'uploadUrl' => wfExpandUrl( wfScript( 'api' ) ) . "?" .
-                                       wfArrayToCGI( array(
-                                               'action' => 'upload',
-                                               'token' => $wgUser->editToken(),
-                                               'format' => 'json',
-                                               'filename' => $this->mDesiredDestName,
-                                               'enablechunks' => 'true',
-                                               'chunksession' =>
-                                               $this->setupChunkSession( $comment, $pageText, $watch ) ) ) ) );
-               } else if ( $this->chunkMode == self::CHUNK ) {
-                       $this->setupChunkSession();
-                       $this->appendChunk();
-                       if ( !$this->status->isOK() ) {
-                               return $this->status;
-                       }
-                       // return success:
-                       // firefogg expects a specific result
-                       // http://www.firefogg.org/dev/chunk_post.html
-                       return Status::newGood(
-                               array( 'result' => 1, 'filesize' => $this->mFileSize )
-                       );
-               } else if ( $this->chunkMode == self::DONE ) {
-                       $this->finalizeFile();
-                       // We ignore the passed-in parameters because these were set on the first contact.
-                       $status = parent::performUpload( $this->comment, $this->pageText, $this->watch, $user );
-
-                       if ( !$status->isGood() ) {
-                               return $status;
-                       }
-                       $file = $this->getLocalFile();
+       public function concatenateChunks() {
+               wfDebug( __METHOD__ . " concatenate {$this->mChunkIndex} chunks:" . 
+                       $this->getOffset() . ' inx:' . $this->getChunkIndex() . "\n" );
 
-                       // firefogg expects a specific result
-                       // http://www.firefogg.org/dev/chunk_post.html
-                       return Status::newGood(
-                               array( 'result' => 1, 'done' => 1, 'resultUrl' => wfExpandUrl( $file->getDescriptionUrl() ) )
-                       );
+               // Concatenate all the chunks to mVirtualTempPath
+               $fileList = Array();
+               // The first chunk is stored at the mVirtualTempPath path so we start on "chunk 1"
+               for( $i = 0; $i <= $this->getChunkIndex(); $i++ ){
+                       $fileList[] = $this->getVirtualChunkLocation( $i );
                }
 
-               return Status::newGood();
+               // Get the file extension from the last chunk
+               $ext = FileBackend::extensionFromPath( $this->mVirtualTempPath );
+               // Get a 0-byte temp file to perform the concatenation at
+               $tmpFile = TempFSFile::factory( 'chunkedupload_', $ext );
+               $tmpPath = $tmpFile
+                       ? $tmpFile->getPath()
+                       : false; // fail in concatenate()
+               // Concatenate the chunks at the temp file
+               $status = $this->repo->concatenate( $fileList, $tmpPath, FileRepo::DELETE_SOURCE );
+               if( !$status->isOk() ){
+                       return $status; 
+               }
+               // Update the mTempPath and mLocalFile
+               // ( for FileUpload or normal Stash to take over )  
+               $this->mTempPath = $tmpPath; // file system path
+               $this->mLocalFile = parent::stashFile();
+
+               return $status;
        }
 
        /**
-        * Append a chunk to the Repo file
-        *
-        * @param string $srcPath Path to file to append from
-        * @param string $toAppendPath Path to file to append to
-        * @return Status Status
+        * Perform the upload, then remove the temp copy afterward
+        * @param $comment string
+        * @param $pageText string
+        * @param $watch bool
+        * @param $user User
+        * @return Status
         */
-       protected function appendToUploadFile( $srcPath, $toAppendPath ) {
-               $repo = RepoGroup::singleton()->getLocalRepo();
-               $status = $repo->append( $srcPath, $toAppendPath );
-               return $status;
+       public function performUpload( $comment, $pageText, $watch, $user ) {
+               $rv = parent::performUpload( $comment, $pageText, $watch, $user );
+               return $rv;
        }
 
        /**
-        * Append a chunk to the temporary file.
-        *
-        * @return void
+        * Returns the virtual chunk location:  
+        * @param $index
+        * @return string
         */
-       protected function appendChunk() {
-               global $wgMaxUploadSize;
-
-               if ( !$this->repoPath ) {
-                       $this->status = $this->saveTempUploadedFile( $this->mDesiredDestName, $this->mTempPath );
+       function getVirtualChunkLocation( $index ){
+               return $this->repo->getVirtualUrl( 'temp' ) . 
+                               '/' .
+                               $this->repo->getHashPath( 
+                                       $this->getChunkFileKey( $index )
+                               ) . 
+                               $this->getChunkFileKey( $index );
+       }
 
-                       if ( $this->status->isOK() ) {
-                               $this->repoPath = $this->status->value;
-                               $_SESSION['wsUploadData'][$this->sessionKey]['repoPath'] = $this->repoPath;
+       /**
+        * Add a chunk to the temporary directory
+        *
+        * @param $chunkPath string path to temporary chunk file
+        * @param $chunkSize int size of the current chunk
+        * @param $offset int offset of current chunk ( mutch match database chunk offset )
+        * @return Status
+        */
+       public function addChunk( $chunkPath, $chunkSize, $offset ) {
+               // Get the offset before we add the chunk to the file system
+               $preAppendOffset = $this->getOffset();
+               
+               if ( $preAppendOffset + $chunkSize > $this->getMaxUploadSize()) {
+                       $status = Status::newFatal( 'file-too-large' );
+               } else {
+                       // Make sure the client is uploading the correct chunk with a matching offset.
+                       if ( $preAppendOffset == $offset ) {
+                               // Update local chunk index for the current chunk   
+                               $this->mChunkIndex++;
+                               $status = $this->outputChunk( $chunkPath );
+                               if( $status->isGood() ){
+                                       // Update local offset: 
+                                       $this->mOffset = $preAppendOffset + $chunkSize;
+                                       // Update chunk table status db         
+                                       $this->updateChunkStatus();             
+                               }
+                       } else {
+                               $status = Status::newFatal( 'invalid-chunk-offset' );
                        }
-                       return;
                }
-               if ( $this->getRealPath( $this->repoPath ) ) {
-                       $this->status = $this->appendToUploadFile( $this->repoPath, $this->mTempPath );
+               return $status;
+       }
 
-                       if ( $this->mFileSize > $wgMaxUploadSize )
-                               $this->status = Status::newFatal( 'largefileserver' );
+       /**
+        * Update the chunk db table with the current status: 
+        */
+       private function updateChunkStatus(){
+               wfDebug( __METHOD__ . " update chunk status for {$this->mFileKey} offset:" . 
+                                       $this->getOffset() . ' inx:' . $this->getChunkIndex() . "\n" );
 
-               } else {
-                       $this->status = Status::newFatal( 'filenotfound', $this->repoPath );
+               $dbw = $this->repo->getMasterDb();
+               $dbw->update(
+                       'uploadstash',
+                       array( 
+                               'us_status' => 'chunks',
+                               'us_chunk_inx' => $this->getChunkIndex(),
+                               'us_size' => $this->getOffset()
+                       ),
+                       array( 'us_key' => $this->mFileKey ),
+                       __METHOD__
+               );
+       }
+
+       /**
+        * Get the chunk db state and populate update relevant local values
+        */
+       private function getChunkStatus(){
+               // get Master db to avoid race conditions. 
+               // Otherwise, if chunk upload time < replag there will be spurious errors
+               $dbw = $this->repo->getMasterDb();
+               $row = $dbw->selectRow(
+                       'uploadstash', 
+                       array( 
+                               'us_chunk_inx',
+                               'us_size',
+                               'us_path',
+                       ),
+                       array( 'us_key' => $this->mFileKey ),
+                       __METHOD__
+               );
+               // Handle result:
+               if ( $row ) {
+                       $this->mChunkIndex = $row->us_chunk_inx;
+                       $this->mOffset = $row->us_size;
+                       $this->mVirtualTempPath = $row->us_path;
                }
        }
 
        /**
-        * Append the final chunk and ready file for parent::performUpload()
-        * @return void
+        * Get the current Chunk index 
+        * @return Integer index of the current chunk
         */
-       protected function finalizeFile() {
-               $this->appendChunk();
-               $this->mTempPath = $this->getRealPath( $this->repoPath );
+       private function getChunkIndex(){
+               if( $this->mChunkIndex !== null ){
+                       return $this->mChunkIndex;
+               }
+               return 0;
        }
 
-       public function verifyUpload() {
-               if ( $this->chunkMode != self::DONE ) {
-                       return array( 'status' => UploadBase::OK );
+       /**
+        * Gets the current offset in fromt the stashedupload table 
+        * @return Integer current byte offset of the chunk file set 
+        */
+       private function getOffset(){
+               if ( $this->mOffset !== null ){
+                       return $this->mOffset;
                }
-               return parent::verifyUpload();
+               return 0;
        }
 
-       public function checkWarnings() {
-               if ( $this->chunkMode != self::DONE ) {
-                       return null;
+       /**
+        * Output the chunk to disk
+        *
+        * @param $chunkPath string
+        * @throws UploadChunkFileException
+        * @return FileRepoStatus
+        */
+       private function outputChunk( $chunkPath ){
+               // Key is fileKey + chunk index
+               $fileKey = $this->getChunkFileKey();
+               
+               // Store the chunk per its indexed fileKey: 
+               $hashPath = $this->repo->getHashPath( $fileKey );
+               $storeStatus = $this->repo->store( $chunkPath, 'temp', "$hashPath$fileKey" );
+               
+               // Check for error in stashing the chunk:
+               if ( ! $storeStatus->isOK() ) {
+                       $error = $storeStatus->getErrorsArray();
+                       $error = reset( $error );
+                       if ( ! count( $error ) ) {
+                               $error = $storeStatus->getWarningsArray();
+                               $error = reset( $error );
+                               if ( ! count( $error ) ) {
+                                       $error = array( 'unknown', 'no error recorded' );
+                               }
+                       }
+                       throw new UploadChunkFileException( "error storing file in '$chunkPath': " . implode( '; ', $error ) );
                }
-               return parent::checkWarnings();
+               return $storeStatus;
        }
 
-       public function getImageInfo( $result ) {
-               if ( $this->chunkMode != self::DONE ) {
-                       return null;
+       private function getChunkFileKey( $index = null ){
+               if( $index === null ){
+                       $index = $this->getChunkIndex();
                }
-               return parent::getImageInfo( $result );
+               return $this->mFileKey . '.' . $index ;
        }
 }
+
+class UploadChunkZeroLengthFileException extends MWException {};
+class UploadChunkFileException extends MWException {};