<?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
+ * Continue chunk uploading
+ */
+ public function continueChunks( $name, $key, $webRequestUpload ) {
+ $this->mFileKey = $key;
+ $this->mUpload = $webRequestUpload;
+ // Get the chunk status form the db:
+ $this->getChunkStatus();
+
+ $metadata = $this->stash->getMetadata( $key );
+ $this->initializePathInfo( $name,
+ $this->getRealPath( $metadata['us_path'] ),
+ $metadata['us_size'],
+ false
+ );
+ }
+
+ /**
+ * Append the final chunk and ready file for parent::performUpload()
+ * @return FileRepoStatus
*/
- 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;
+ public function concatenateChunks() {
+ wfDebug( __METHOD__ . " concatenate {$this->mChunkIndex} chunks:" .
+ $this->getOffset() . ' inx:' . $this->getChunkIndex() . "\n" );
- 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];
- }
- }
+ // 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 );
+ }
- $this->mFileSize += $fileSize;
- } else {
- $this->status = Status::newFatal( 'invalid-session-key' );
+ // 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;
}
/**
- * Handle a chunk of the upload. Overrides the parent method
- * because Chunked Uploading clients (i.e. Firefogg) require
- * specific API responses.
- * @see UploadBase::performUpload
+ * Perform the upload, then remove the temp copy afterward
+ * @param $comment string
+ * @param $pageText string
+ * @param $watch bool
+ * @param $user User
+ * @return Status
*/
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();
-
- // 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() ) )
- );
- }
-
- return Status::newGood();
+ $rv = parent::performUpload( $comment, $pageText, $watch, $user );
+ return $rv;
}
/**
- * 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
+ * Returns the virtual chunk location:
+ * @param $index
+ * @return string
*/
- protected function appendToUploadFile( $srcPath, $toAppendPath ) {
- $repo = RepoGroup::singleton()->getLocalRepo();
- $status = $repo->append( $srcPath, $toAppendPath );
- return $status;
+ function getVirtualChunkLocation( $index ){
+ return $this->repo->getVirtualUrl( 'temp' ) .
+ '/' .
+ $this->repo->getHashPath(
+ $this->getChunkFileKey( $index )
+ ) .
+ $this->getChunkFileKey( $index );
}
/**
- * Append a chunk to the temporary file.
+ * Add a chunk to the temporary directory
*
- * @return void
+ * @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
*/
- protected function appendChunk() {
- global $wgMaxUploadSize;
-
- if ( !$this->repoPath ) {
- $this->status = $this->saveTempUploadedFile( $this->mDesiredDestName, $this->mTempPath );
-
- if ( $this->status->isOK() ) {
- $this->repoPath = $this->status->value;
- $_SESSION['wsUploadData'][$this->sessionKey]['repoPath'] = $this->repoPath;
+ 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 );
-
- if ( $this->mFileSize > $wgMaxUploadSize )
- $this->status = Status::newFatal( 'largefileserver' );
-
- } else {
- $this->status = Status::newFatal( 'filenotfound', $this->repoPath );
+ return $status;
+ }
+
+ /**
+ * 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" );
+
+ $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
+ * @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 {};