Merge "Update docs/hooks.txt for ShowSearchHitTitle"
[mediawiki.git] / resources / src / mediawiki / api / upload.js
blob351ceb2ca36586a2a2752f93f2624078d084c573
1 /**
2  * Provides an interface for uploading files to MediaWiki.
3  *
4  * @class mw.Api.plugin.upload
5  * @singleton
6  */
7 ( function ( mw, $ ) {
8         var nonce = 0,
9                 fieldsAllowed = {
10                         stash: true,
11                         filekey: true,
12                         filename: true,
13                         comment: true,
14                         text: true,
15                         watchlist: true,
16                         ignorewarnings: true
17                 };
19         /**
20          * Get nonce for iframe IDs on the page.
21          *
22          * @private
23          * @return {number}
24          */
25         function getNonce() {
26                 return nonce++;
27         }
29         /**
30          * Given a non-empty object, return one of its keys.
31          *
32          * @private
33          * @param {Object} obj
34          * @return {string}
35          */
36         function getFirstKey( obj ) {
37                 var key;
38                 for ( key in obj ) {
39                         if ( obj.hasOwnProperty( key ) ) {
40                                 return key;
41                         }
42                 }
43         }
45         /**
46          * Get new iframe object for an upload.
47          *
48          * @private
49          * @param {string} id
50          * @return {HTMLIframeElement}
51          */
52         function getNewIframe( id ) {
53                 var frame = document.createElement( 'iframe' );
54                 frame.id = id;
55                 frame.name = id;
56                 return frame;
57         }
59         /**
60          * Shortcut for getting hidden inputs
61          *
62          * @private
63          * @param {string} name
64          * @param {string} val
65          * @return {jQuery}
66          */
67         function getHiddenInput( name, val ) {
68                 return $( '<input>' ).attr( 'type', 'hidden' )
69                         .attr( 'name', name )
70                         .val( val );
71         }
73         /**
74          * Process the result of the form submission, returned to an iframe.
75          * This is the iframe's onload event.
76          *
77          * @param {HTMLIframeElement} iframe Iframe to extract result from
78          * @return {Object} Response from the server. The return value may or may
79          *   not be an XMLDocument, this code was copied from elsewhere, so if you
80          *   see an unexpected return type, please file a bug.
81          */
82         function processIframeResult( iframe ) {
83                 var json,
84                         doc = iframe.contentDocument || frames[ iframe.id ].document;
86                 if ( doc.XMLDocument ) {
87                         // The response is a document property in IE
88                         return doc.XMLDocument;
89                 }
91                 if ( doc.body ) {
92                         // Get the json string
93                         // We're actually searching through an HTML doc here --
94                         // according to mdale we need to do this
95                         // because IE does not load JSON properly in an iframe
96                         json = $( doc.body ).find( 'pre' ).text();
98                         return JSON.parse( json );
99                 }
101                 // Response is a xml document
102                 return doc;
103         }
105         function formDataAvailable() {
106                 return window.FormData !== undefined &&
107                         window.File !== undefined &&
108                         window.File.prototype.slice !== undefined;
109         }
111         $.extend( mw.Api.prototype, {
112                 /**
113                  * Upload a file to MediaWiki.
114                  *
115                  * The file will be uploaded using AJAX and FormData, if the browser supports it, or via an
116                  * iframe if it doesn't.
117                  *
118                  * Caveats of iframe upload:
119                  * - The returned jQuery.Promise will not receive `progress` notifications during the upload
120                  * - It is incompatible with uploads to a foreign wiki using mw.ForeignApi
121                  * - You must pass a HTMLInputElement and not a File for it to be possible
122                  *
123                  * @param {HTMLInputElement|File|Blob} file HTML input type=file element with a file already inside
124                  *  of it, or a File object.
125                  * @param {Object} data Other upload options, see action=upload API docs for more
126                  * @return {jQuery.Promise}
127                  */
128                 upload: function ( file, data ) {
129                         var isFileInput, canUseFormData;
131                         isFileInput = file && file.nodeType === Node.ELEMENT_NODE;
133                         if ( formDataAvailable() && isFileInput && file.files ) {
134                                 file = file.files[ 0 ];
135                         }
137                         if ( !file ) {
138                                 throw new Error( 'No file' );
139                         }
141                         // Blobs are allowed in formdata uploads, it turns out
142                         canUseFormData = formDataAvailable() && ( file instanceof window.File || file instanceof window.Blob );
144                         if ( !isFileInput && !canUseFormData ) {
145                                 throw new Error( 'Unsupported argument type passed to mw.Api.upload' );
146                         }
148                         if ( canUseFormData ) {
149                                 return this.uploadWithFormData( file, data );
150                         }
152                         return this.uploadWithIframe( file, data );
153                 },
155                 /**
156                  * Upload a file to MediaWiki with an iframe and a form.
157                  *
158                  * This method is necessary for browsers without the File/FormData
159                  * APIs, and continues to work in browsers with those APIs.
160                  *
161                  * The rough sketch of how this method works is as follows:
162                  * 1. An iframe is loaded with no content.
163                  * 2. A form is submitted with the passed-in file input and some extras.
164                  * 3. The MediaWiki API receives that form data, and sends back a response.
165                  * 4. The response is sent to the iframe, because we set target=(iframe id)
166                  * 5. The response is parsed out of the iframe's document, and passed back
167                  *    through the promise.
168                  *
169                  * @private
170                  * @param {HTMLInputElement} file The file input with a file in it.
171                  * @param {Object} data Other upload options, see action=upload API docs for more
172                  * @return {jQuery.Promise}
173                  */
174                 uploadWithIframe: function ( file, data ) {
175                         var key,
176                                 tokenPromise = $.Deferred(),
177                                 api = this,
178                                 deferred = $.Deferred(),
179                                 nonce = getNonce(),
180                                 id = 'uploadframe-' + nonce,
181                                 $form = $( '<form>' ),
182                                 iframe = getNewIframe( id ),
183                                 $iframe = $( iframe );
185                         for ( key in data ) {
186                                 if ( !fieldsAllowed[ key ] ) {
187                                         delete data[ key ];
188                                 }
189                         }
191                         data = $.extend( {}, this.defaults.parameters, { action: 'upload' }, data );
192                         $form.addClass( 'mw-api-upload-form' );
194                         $form.css( 'display', 'none' )
195                                 .attr( {
196                                         action: this.defaults.ajax.url,
197                                         method: 'POST',
198                                         target: id,
199                                         enctype: 'multipart/form-data'
200                                 } );
202                         $iframe.one( 'load', function () {
203                                 $iframe.one( 'load', function () {
204                                         var result = processIframeResult( iframe );
205                                         deferred.notify( 1 );
207                                         if ( !result ) {
208                                                 deferred.reject( 'ok-but-empty', 'No response from API on upload attempt.' );
209                                         } else if ( result.error ) {
210                                                 if ( result.error.code === 'badtoken' ) {
211                                                         api.badToken( 'csrf' );
212                                                 }
214                                                 deferred.reject( result.error.code, result );
215                                         } else if ( result.upload && result.upload.warnings ) {
216                                                 deferred.reject( getFirstKey( result.upload.warnings ), result );
217                                         } else {
218                                                 deferred.resolve( result );
219                                         }
220                                 } );
221                                 tokenPromise.done( function () {
222                                         $form.submit();
223                                 } );
224                         } );
226                         $iframe.on( 'error', function ( error ) {
227                                 deferred.reject( 'http', error );
228                         } );
230                         $iframe.prop( 'src', 'about:blank' ).hide();
232                         file.name = 'file';
234                         $.each( data, function ( key, val ) {
235                                 $form.append( getHiddenInput( key, val ) );
236                         } );
238                         if ( !data.filename && !data.stash ) {
239                                 throw new Error( 'Filename not included in file data.' );
240                         }
242                         if ( this.needToken() ) {
243                                 this.getEditToken().then( function ( token ) {
244                                         $form.append( getHiddenInput( 'token', token ) );
245                                         tokenPromise.resolve();
246                                 }, tokenPromise.reject );
247                         } else {
248                                 tokenPromise.resolve();
249                         }
251                         $( 'body' ).append( $form, $iframe );
253                         deferred.always( function () {
254                                 $form.remove();
255                                 $iframe.remove();
256                         } );
258                         return deferred.promise();
259                 },
261                 /**
262                  * Uploads a file using the FormData API.
263                  *
264                  * @private
265                  * @param {File} file
266                  * @param {Object} data Other upload options, see action=upload API docs for more
267                  * @return {jQuery.Promise}
268                  */
269                 uploadWithFormData: function ( file, data ) {
270                         var key,
271                                 deferred = $.Deferred();
273                         for ( key in data ) {
274                                 if ( !fieldsAllowed[ key ] ) {
275                                         delete data[ key ];
276                                 }
277                         }
279                         data = $.extend( {}, this.defaults.parameters, { action: 'upload' }, data );
280                         data.file = file;
282                         if ( !data.filename && !data.stash ) {
283                                 throw new Error( 'Filename not included in file data.' );
284                         }
286                         // Use this.postWithEditToken() or this.post()
287                         this[ this.needToken() ? 'postWithEditToken' : 'post' ]( data, {
288                                 // Use FormData (if we got here, we know that it's available)
289                                 contentType: 'multipart/form-data',
290                                 // No timeout (default from mw.Api is 30 seconds)
291                                 timeout: 0,
292                                 // Provide upload progress notifications
293                                 xhr: function () {
294                                         var xhr = $.ajaxSettings.xhr();
295                                         if ( xhr.upload ) {
296                                                 // need to bind this event before we open the connection (see note at
297                                                 // https://developer.mozilla.org/en-US/docs/DOM/XMLHttpRequest/Using_XMLHttpRequest#Monitoring_progress)
298                                                 xhr.upload.addEventListener( 'progress', function ( ev ) {
299                                                         if ( ev.lengthComputable ) {
300                                                                 deferred.notify( ev.loaded / ev.total );
301                                                         }
302                                                 } );
303                                         }
304                                         return xhr;
305                                 }
306                         } )
307                                 .done( function ( result ) {
308                                         deferred.notify( 1 );
309                                         if ( result.upload && result.upload.warnings ) {
310                                                 deferred.reject( getFirstKey( result.upload.warnings ), result );
311                                         } else {
312                                                 deferred.resolve( result );
313                                         }
314                                 } )
315                                 .fail( function ( errorCode, result ) {
316                                         deferred.notify( 1 );
317                                         deferred.reject( errorCode, result );
318                                 } );
320                         return deferred.promise();
321                 },
323                 /**
324                  * Upload a file to the stash.
325                  *
326                  * This function will return a promise, which when resolved, will pass back a function
327                  * to finish the stash upload. You can call that function with an argument containing
328                  * more, or conflicting, data to pass to the server. For example:
329                  *
330                  *     // upload a file to the stash with a placeholder filename
331                  *     api.uploadToStash( file, { filename: 'testing.png' } ).done( function ( finish ) {
332                  *         // finish is now the function we can use to finalize the upload
333                  *         // pass it a new filename from user input to override the initial value
334                  *         finish( { filename: getFilenameFromUser() } ).done( function ( data ) {
335                  *             // the upload is complete, data holds the API response
336                  *         } );
337                  *     } );
338                  *
339                  * @param {File|HTMLInputElement} file
340                  * @param {Object} [data]
341                  * @return {jQuery.Promise}
342                  * @return {Function} return.finishStashUpload Call this function to finish the upload.
343                  * @return {Object} return.finishStashUpload.data Additional data for the upload.
344                  * @return {jQuery.Promise} return.finishStashUpload.return API promise for the final upload
345                  * @return {Object} return.finishStashUpload.return.data API return value for the final upload
346                  */
347                 uploadToStash: function ( file, data ) {
348                         var filekey,
349                                 api = this;
351                         if ( !data.filename ) {
352                                 throw new Error( 'Filename not included in file data.' );
353                         }
355                         function finishUpload( moreData ) {
356                                 return api.uploadFromStash( filekey, $.extend( data, moreData ) );
357                         }
359                         return this.upload( file, { stash: true, filename: data.filename } ).then(
360                                 function ( result ) {
361                                         filekey = result.upload.filekey;
362                                         return finishUpload;
363                                 },
364                                 function ( errorCode, result ) {
365                                         if ( result && result.upload && result.upload.filekey ) {
366                                                 // Ignore any warnings if 'filekey' was returned, that's all we care about
367                                                 filekey = result.upload.filekey;
368                                                 return $.Deferred().resolve( finishUpload );
369                                         }
370                                         return $.Deferred().reject( errorCode, result );
371                                 }
372                         );
373                 },
375                 /**
376                  * Finish an upload in the stash.
377                  *
378                  * @param {string} filekey
379                  * @param {Object} data
380                  * @return {jQuery.Promise}
381                  */
382                 uploadFromStash: function ( filekey, data ) {
383                         data.filekey = filekey;
384                         data.action = 'upload';
385                         data.format = 'json';
387                         if ( !data.filename ) {
388                                 throw new Error( 'Filename not included in file data.' );
389                         }
391                         return this.postWithEditToken( data ).then( function ( result ) {
392                                 if ( result.upload && result.upload.warnings ) {
393                                         return $.Deferred().reject( getFirstKey( result.upload.warnings ), result ).promise();
394                                 }
395                                 return result;
396                         } );
397                 },
399                 needToken: function () {
400                         return true;
401                 }
402         } );
404         /**
405          * @class mw.Api
406          * @mixins mw.Api.plugin.upload
407          */
408 }( mediaWiki, jQuery ) );