Merge "Upstream EasyDeflate library from VisualEditor"
[lhc/web/wiklou.git] / resources / src / mediawiki.api / upload.js
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 chunk: true,
18 offset: true,
19 filesize: true,
20 async: true
21 };
22
23 /**
24 * Get nonce for iframe IDs on the page.
25 *
26 * @private
27 * @return {number}
28 */
29 function getNonce() {
30 return nonce++;
31 }
32
33 /**
34 * Given a non-empty object, return one of its keys.
35 *
36 * @private
37 * @param {Object} obj
38 * @return {string}
39 */
40 function getFirstKey( obj ) {
41 var key;
42 for ( key in obj ) {
43 return key;
44 }
45 }
46
47 /**
48 * Get new iframe object for an upload.
49 *
50 * @private
51 * @param {string} id
52 * @return {HTMLIframeElement}
53 */
54 function getNewIframe( id ) {
55 var frame = document.createElement( 'iframe' );
56 frame.id = id;
57 frame.name = id;
58 return frame;
59 }
60
61 /**
62 * Shortcut for getting hidden inputs
63 *
64 * @private
65 * @param {string} name
66 * @param {string} val
67 * @return {jQuery}
68 */
69 function getHiddenInput( name, val ) {
70 return $( '<input>' ).attr( 'type', 'hidden' )
71 .attr( 'name', name )
72 .val( val );
73 }
74
75 /**
76 * Process the result of the form submission, returned to an iframe.
77 * This is the iframe's onload event.
78 *
79 * @param {HTMLIframeElement} iframe Iframe to extract result from
80 * @return {Object} Response from the server. The return value may or may
81 * not be an XMLDocument, this code was copied from elsewhere, so if you
82 * see an unexpected return type, please file a bug.
83 */
84 function processIframeResult( iframe ) {
85 var json,
86 doc = iframe.contentDocument || frames[ iframe.id ].document;
87
88 if ( doc.XMLDocument ) {
89 // The response is a document property in IE
90 return doc.XMLDocument;
91 }
92
93 if ( doc.body ) {
94 // Get the json string
95 // We're actually searching through an HTML doc here --
96 // according to mdale we need to do this
97 // because IE does not load JSON properly in an iframe
98 json = $( doc.body ).find( 'pre' ).text();
99
100 return JSON.parse( json );
101 }
102
103 // Response is a xml document
104 return doc;
105 }
106
107 function formDataAvailable() {
108 return window.FormData !== undefined &&
109 window.File !== undefined &&
110 window.File.prototype.slice !== undefined;
111 }
112
113 $.extend( mw.Api.prototype, {
114 /**
115 * Upload a file to MediaWiki.
116 *
117 * The file will be uploaded using AJAX and FormData, if the browser supports it, or via an
118 * iframe if it doesn't.
119 *
120 * Caveats of iframe upload:
121 * - The returned jQuery.Promise will not receive `progress` notifications during the upload
122 * - It is incompatible with uploads to a foreign wiki using mw.ForeignApi
123 * - You must pass a HTMLInputElement and not a File for it to be possible
124 *
125 * @param {HTMLInputElement|File|Blob} file HTML input type=file element with a file already inside
126 * of it, or a File object.
127 * @param {Object} data Other upload options, see action=upload API docs for more
128 * @return {jQuery.Promise}
129 */
130 upload: function ( file, data ) {
131 var isFileInput, canUseFormData;
132
133 isFileInput = file && file.nodeType === Node.ELEMENT_NODE;
134
135 if ( formDataAvailable() && isFileInput && file.files ) {
136 file = file.files[ 0 ];
137 }
138
139 if ( !file ) {
140 throw new Error( 'No file' );
141 }
142
143 // Blobs are allowed in formdata uploads, it turns out
144 canUseFormData = formDataAvailable() && ( file instanceof window.File || file instanceof window.Blob );
145
146 if ( !isFileInput && !canUseFormData ) {
147 throw new Error( 'Unsupported argument type passed to mw.Api.upload' );
148 }
149
150 if ( canUseFormData ) {
151 return this.uploadWithFormData( file, data );
152 }
153
154 return this.uploadWithIframe( file, data );
155 },
156
157 /**
158 * Upload a file to MediaWiki with an iframe and a form.
159 *
160 * This method is necessary for browsers without the File/FormData
161 * APIs, and continues to work in browsers with those APIs.
162 *
163 * The rough sketch of how this method works is as follows:
164 * 1. An iframe is loaded with no content.
165 * 2. A form is submitted with the passed-in file input and some extras.
166 * 3. The MediaWiki API receives that form data, and sends back a response.
167 * 4. The response is sent to the iframe, because we set target=(iframe id)
168 * 5. The response is parsed out of the iframe's document, and passed back
169 * through the promise.
170 *
171 * @private
172 * @param {HTMLInputElement} file The file input with a file in it.
173 * @param {Object} data Other upload options, see action=upload API docs for more
174 * @return {jQuery.Promise}
175 */
176 uploadWithIframe: function ( file, data ) {
177 var key,
178 tokenPromise = $.Deferred(),
179 api = this,
180 deferred = $.Deferred(),
181 nonce = getNonce(),
182 id = 'uploadframe-' + nonce,
183 $form = $( '<form>' ),
184 iframe = getNewIframe( id ),
185 $iframe = $( iframe );
186
187 for ( key in data ) {
188 if ( !fieldsAllowed[ key ] ) {
189 delete data[ key ];
190 }
191 }
192
193 data = $.extend( {}, this.defaults.parameters, { action: 'upload' }, data );
194 $form.addClass( 'mw-api-upload-form' );
195
196 $form.css( 'display', 'none' )
197 .attr( {
198 action: this.defaults.ajax.url,
199 method: 'POST',
200 target: id,
201 enctype: 'multipart/form-data'
202 } );
203
204 $iframe.one( 'load', function () {
205 $iframe.one( 'load', function () {
206 var result = processIframeResult( iframe );
207 deferred.notify( 1 );
208
209 if ( !result ) {
210 deferred.reject( 'ok-but-empty', 'No response from API on upload attempt.' );
211 } else if ( result.error ) {
212 if ( result.error.code === 'badtoken' ) {
213 api.badToken( 'csrf' );
214 }
215
216 deferred.reject( result.error.code, result );
217 } else if ( result.upload && result.upload.warnings ) {
218 deferred.reject( getFirstKey( result.upload.warnings ), result );
219 } else {
220 deferred.resolve( result );
221 }
222 } );
223 tokenPromise.done( function () {
224 $form.submit();
225 } );
226 } );
227
228 $iframe.on( 'error', function ( error ) {
229 deferred.reject( 'http', error );
230 } );
231
232 $iframe.prop( 'src', 'about:blank' ).hide();
233
234 file.name = 'file';
235
236 $.each( data, function ( key, val ) {
237 $form.append( getHiddenInput( key, val ) );
238 } );
239
240 if ( !data.filename && !data.stash ) {
241 throw new Error( 'Filename not included in file data.' );
242 }
243
244 if ( this.needToken() ) {
245 this.getEditToken().then( function ( token ) {
246 $form.append( getHiddenInput( 'token', token ) );
247 tokenPromise.resolve();
248 }, tokenPromise.reject );
249 } else {
250 tokenPromise.resolve();
251 }
252
253 $( 'body' ).append( $form, $iframe );
254
255 deferred.always( function () {
256 $form.remove();
257 $iframe.remove();
258 } );
259
260 return deferred.promise();
261 },
262
263 /**
264 * Uploads a file using the FormData API.
265 *
266 * @private
267 * @param {File} file
268 * @param {Object} data Other upload options, see action=upload API docs for more
269 * @return {jQuery.Promise}
270 */
271 uploadWithFormData: function ( file, data ) {
272 var key, request,
273 deferred = $.Deferred();
274
275 for ( key in data ) {
276 if ( !fieldsAllowed[ key ] ) {
277 delete data[ key ];
278 }
279 }
280
281 data = $.extend( {}, this.defaults.parameters, { action: 'upload' }, data );
282 if ( !data.chunk ) {
283 data.file = file;
284 }
285
286 if ( !data.filename && !data.stash ) {
287 throw new Error( 'Filename not included in file data.' );
288 }
289
290 // Use this.postWithEditToken() or this.post()
291 request = this[ this.needToken() ? 'postWithEditToken' : 'post' ]( data, {
292 // Use FormData (if we got here, we know that it's available)
293 contentType: 'multipart/form-data',
294 // No timeout (default from mw.Api is 30 seconds)
295 timeout: 0,
296 // Provide upload progress notifications
297 xhr: function () {
298 var xhr = $.ajaxSettings.xhr();
299 if ( xhr.upload ) {
300 // need to bind this event before we open the connection (see note at
301 // https://developer.mozilla.org/en-US/docs/DOM/XMLHttpRequest/Using_XMLHttpRequest#Monitoring_progress)
302 xhr.upload.addEventListener( 'progress', function ( ev ) {
303 if ( ev.lengthComputable ) {
304 deferred.notify( ev.loaded / ev.total );
305 }
306 } );
307 }
308 return xhr;
309 }
310 } )
311 .done( function ( result ) {
312 deferred.notify( 1 );
313 if ( result.upload && result.upload.warnings ) {
314 deferred.reject( getFirstKey( result.upload.warnings ), result );
315 } else {
316 deferred.resolve( result );
317 }
318 } )
319 .fail( function ( errorCode, result ) {
320 deferred.notify( 1 );
321 deferred.reject( errorCode, result );
322 } );
323
324 return deferred.promise( { abort: request.abort } );
325 },
326
327 /**
328 * Upload a file in several chunks.
329 *
330 * @param {File} file
331 * @param {Object} data Other upload options, see action=upload API docs for more
332 * @param {number} [chunkSize] Size (in bytes) per chunk (default: 5MB)
333 * @param {number} [chunkRetries] Amount of times to retry a failed chunk (default: 1)
334 * @return {jQuery.Promise}
335 */
336 chunkedUpload: function ( file, data, chunkSize, chunkRetries ) {
337 var start, end, promise, next, active,
338 deferred = $.Deferred();
339
340 chunkSize = chunkSize === undefined ? 5 * 1024 * 1024 : chunkSize;
341 chunkRetries = chunkRetries === undefined ? 1 : chunkRetries;
342
343 if ( !data.filename ) {
344 throw new Error( 'Filename not included in file data.' );
345 }
346
347 // Submit first chunk to get the filekey
348 active = promise = this.uploadChunk( file, data, 0, chunkSize, '', chunkRetries )
349 .done( chunkSize >= file.size ? deferred.resolve : null )
350 .fail( deferred.reject )
351 .progress( deferred.notify );
352
353 // Now iteratively submit the rest of the chunks
354 for ( start = chunkSize; start < file.size; start += chunkSize ) {
355 end = Math.min( start + chunkSize, file.size );
356 next = $.Deferred();
357
358 // We could simply chain one this.uploadChunk after another with
359 // .then(), but then we'd hit an `Uncaught RangeError: Maximum
360 // call stack size exceeded` at as low as 1024 calls in Firefox
361 // 47. This'll work around it, but comes with the drawback of
362 // having to properly relay the results to the returned promise.
363 // eslint-disable-next-line no-loop-func
364 promise.done( function ( start, end, next, result ) {
365 var filekey = result.upload.filekey;
366 active = this.uploadChunk( file, data, start, end, filekey, chunkRetries )
367 .done( end === file.size ? deferred.resolve : next.resolve )
368 .fail( deferred.reject )
369 .progress( deferred.notify );
370 // start, end & next must be bound to closure, or they'd have
371 // changed by the time the promises are resolved
372 }.bind( this, start, end, next ) );
373
374 promise = next;
375 }
376
377 return deferred.promise( { abort: active.abort } );
378 },
379
380 /**
381 * Uploads 1 chunk.
382 *
383 * @private
384 * @param {File} file
385 * @param {Object} data Other upload options, see action=upload API docs for more
386 * @param {number} start Chunk start position
387 * @param {number} end Chunk end position
388 * @param {string} [filekey] File key, for follow-up chunks
389 * @param {number} [retries] Amount of times to retry request
390 * @return {jQuery.Promise}
391 */
392 uploadChunk: function ( file, data, start, end, filekey, retries ) {
393 var upload,
394 api = this,
395 chunk = this.slice( file, start, end );
396
397 // When uploading in chunks, we're going to be issuing a lot more
398 // requests and there's always a chance of 1 getting dropped.
399 // In such case, it could be useful to try again: a network hickup
400 // doesn't necessarily have to result in upload failure...
401 retries = retries === undefined ? 1 : retries;
402
403 data.filesize = file.size;
404 data.chunk = chunk;
405 data.offset = start;
406
407 // filekey must only be added when uploading follow-up chunks; the
408 // first chunk should never have a filekey (it'll be generated)
409 if ( filekey && start !== 0 ) {
410 data.filekey = filekey;
411 }
412
413 upload = this.uploadWithFormData( file, data );
414 return upload.then(
415 null,
416 function ( code, result ) {
417 var retry;
418
419 // uploadWithFormData will reject uploads with warnings, but
420 // these warnings could be "harmless" or recovered from
421 // (e.g. exists-normalized, when it'll be renamed later)
422 // In the case of (only) a warning, we still want to
423 // continue the chunked upload until it completes: then
424 // reject it - at least it's been fully uploaded by then and
425 // failure handlers have a complete result object (including
426 // possibly more warnings, e.g. duplicate)
427 // This matches .upload, which also completes the upload.
428 if ( result.upload && result.upload.warnings && code in result.upload.warnings ) {
429 if ( end === file.size ) {
430 // uploaded last chunk = reject with result data
431 return $.Deferred().reject( code, result );
432 } else {
433 // still uploading chunks = resolve to keep going
434 return $.Deferred().resolve( result );
435 }
436 }
437
438 if ( retries === 0 ) {
439 return $.Deferred().reject( code, result );
440 }
441
442 // If the call flat out failed, we may want to try again...
443 retry = api.uploadChunk.bind( this, file, data, start, end, filekey, retries - 1 );
444 return api.retry( code, result, retry );
445 },
446 function ( fraction ) {
447 // Since we're only uploading small parts of a file, we
448 // need to adjust the reported progress to reflect where
449 // we actually are in the combined upload
450 return ( start + fraction * ( end - start ) ) / file.size;
451 }
452 ).promise( { abort: upload.abort } );
453 },
454
455 /**
456 * Launch the upload anew if it failed because of network issues.
457 *
458 * @private
459 * @param {string} code Error code
460 * @param {Object} result API result
461 * @param {Function} callable
462 * @return {jQuery.Promise}
463 */
464 retry: function ( code, result, callable ) {
465 var uploadPromise,
466 retryTimer,
467 deferred = $.Deferred(),
468 // Wrap around the callable, so that once it completes, it'll
469 // resolve/reject the promise we'll return
470 retry = function () {
471 uploadPromise = callable();
472 uploadPromise.then( deferred.resolve, deferred.reject );
473 };
474
475 // Don't retry if the request failed because we aborted it (or if
476 // it's another kind of request failure)
477 if ( code !== 'http' || result.textStatus === 'abort' ) {
478 return deferred.reject( code, result );
479 }
480
481 retryTimer = setTimeout( retry, 1000 );
482 return deferred.promise( { abort: function () {
483 // Clear the scheduled upload, or abort if already in flight
484 if ( retryTimer ) {
485 clearTimeout( retryTimer );
486 }
487 if ( uploadPromise.abort ) {
488 uploadPromise.abort();
489 }
490 } } );
491 },
492
493 /**
494 * Slice a chunk out of a File object.
495 *
496 * @private
497 * @param {File} file
498 * @param {number} start
499 * @param {number} stop
500 * @return {Blob}
501 */
502 slice: function ( file, start, stop ) {
503 if ( file.mozSlice ) {
504 // FF <= 12
505 return file.mozSlice( start, stop, file.type );
506 } else if ( file.webkitSlice ) {
507 // Chrome <= 20
508 return file.webkitSlice( start, stop, file.type );
509 } else {
510 // On really old browser versions (before slice was prefixed),
511 // slice() would take (start, length) instead of (start, end)
512 // We'll ignore that here...
513 return file.slice( start, stop, file.type );
514 }
515 },
516
517 /**
518 * This function will handle how uploads to stash (via uploadToStash or
519 * chunkedUploadToStash) are resolved/rejected.
520 *
521 * After a successful stash, it'll resolve with a callback which, when
522 * called, will finalize the upload in stash (with the given data, or
523 * with additional/conflicting data)
524 *
525 * A failed stash can still be recovered from as long as 'filekey' is
526 * present. In that case, it'll also resolve with the callback to
527 * finalize the upload (all warnings are then ignored.)
528 * Otherwise, it'll just reject as you'd expect, with code & result.
529 *
530 * @private
531 * @param {jQuery.Promise} uploadPromise
532 * @param {Object} data
533 * @return {jQuery.Promise}
534 * @return {Function} return.finishUpload Call this function to finish the upload.
535 * @return {Object} return.finishUpload.data Additional data for the upload.
536 * @return {jQuery.Promise} return.finishUpload.return API promise for the final upload
537 * @return {Object} return.finishUpload.return.data API return value for the final upload
538 */
539 finishUploadToStash: function ( uploadPromise, data ) {
540 var filekey,
541 api = this;
542
543 function finishUpload( moreData ) {
544 return api.uploadFromStash( filekey, $.extend( data, moreData ) );
545 }
546
547 return uploadPromise.then(
548 function ( result ) {
549 filekey = result.upload.filekey;
550 return finishUpload;
551 },
552 function ( errorCode, result ) {
553 if ( result && result.upload && result.upload.filekey ) {
554 // Ignore any warnings if 'filekey' was returned, that's all we care about
555 filekey = result.upload.filekey;
556 return $.Deferred().resolve( finishUpload );
557 }
558 return $.Deferred().reject( errorCode, result );
559 }
560 );
561 },
562
563 /**
564 * Upload a file to the stash.
565 *
566 * This function will return a promise, which when resolved, will pass back a function
567 * to finish the stash upload. You can call that function with an argument containing
568 * more, or conflicting, data to pass to the server. For example:
569 *
570 * // upload a file to the stash with a placeholder filename
571 * api.uploadToStash( file, { filename: 'testing.png' } ).done( function ( finish ) {
572 * // finish is now the function we can use to finalize the upload
573 * // pass it a new filename from user input to override the initial value
574 * finish( { filename: getFilenameFromUser() } ).done( function ( data ) {
575 * // the upload is complete, data holds the API response
576 * } );
577 * } );
578 *
579 * @param {File|HTMLInputElement} file
580 * @param {Object} [data]
581 * @return {jQuery.Promise}
582 * @return {Function} return.finishUpload Call this function to finish the upload.
583 * @return {Object} return.finishUpload.data Additional data for the upload.
584 * @return {jQuery.Promise} return.finishUpload.return API promise for the final upload
585 * @return {Object} return.finishUpload.return.data API return value for the final upload
586 */
587 uploadToStash: function ( file, data ) {
588 var promise;
589
590 if ( !data.filename ) {
591 throw new Error( 'Filename not included in file data.' );
592 }
593
594 promise = this.upload( file, { stash: true, filename: data.filename } );
595
596 return this.finishUploadToStash( promise, data );
597 },
598
599 /**
600 * Upload a file to the stash, in chunks.
601 *
602 * This function will return a promise, which when resolved, will pass back a function
603 * to finish the stash upload.
604 *
605 * @see #method-uploadToStash
606 * @param {File|HTMLInputElement} file
607 * @param {Object} [data]
608 * @param {number} [chunkSize] Size (in bytes) per chunk (default: 5MB)
609 * @param {number} [chunkRetries] Amount of times to retry a failed chunk (default: 1)
610 * @return {jQuery.Promise}
611 * @return {Function} return.finishUpload Call this function to finish the upload.
612 * @return {Object} return.finishUpload.data Additional data for the upload.
613 * @return {jQuery.Promise} return.finishUpload.return API promise for the final upload
614 * @return {Object} return.finishUpload.return.data API return value for the final upload
615 */
616 chunkedUploadToStash: function ( file, data, chunkSize, chunkRetries ) {
617 var promise;
618
619 if ( !data.filename ) {
620 throw new Error( 'Filename not included in file data.' );
621 }
622
623 promise = this.chunkedUpload(
624 file,
625 { stash: true, filename: data.filename },
626 chunkSize,
627 chunkRetries
628 );
629
630 return this.finishUploadToStash( promise, data );
631 },
632
633 /**
634 * Finish an upload in the stash.
635 *
636 * @param {string} filekey
637 * @param {Object} data
638 * @return {jQuery.Promise}
639 */
640 uploadFromStash: function ( filekey, data ) {
641 data.filekey = filekey;
642 data.action = 'upload';
643 data.format = 'json';
644
645 if ( !data.filename ) {
646 throw new Error( 'Filename not included in file data.' );
647 }
648
649 return this.postWithEditToken( data ).then( function ( result ) {
650 if ( result.upload && result.upload.warnings ) {
651 return $.Deferred().reject( getFirstKey( result.upload.warnings ), result ).promise();
652 }
653 return result;
654 } );
655 },
656
657 needToken: function () {
658 return true;
659 }
660 } );
661
662 /**
663 * @class mw.Api
664 * @mixins mw.Api.plugin.upload
665 */
666 }( mediaWiki, jQuery ) );