Merge "user: Allow "CAS update failed" exceptions to be normalised"
[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 () {
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 // eslint-disable-next-line no-restricted-properties
237 $.each( data, function ( key, val ) {
238 $form.append( getHiddenInput( key, val ) );
239 } );
240
241 if ( !data.filename && !data.stash ) {
242 throw new Error( 'Filename not included in file data.' );
243 }
244
245 if ( this.needToken() ) {
246 this.getEditToken().then( function ( token ) {
247 $form.append( getHiddenInput( 'token', token ) );
248 tokenPromise.resolve();
249 }, tokenPromise.reject );
250 } else {
251 tokenPromise.resolve();
252 }
253
254 $( 'body' ).append( $form, $iframe );
255
256 deferred.always( function () {
257 $form.remove();
258 $iframe.remove();
259 } );
260
261 return deferred.promise();
262 },
263
264 /**
265 * Uploads a file using the FormData API.
266 *
267 * @private
268 * @param {File} file
269 * @param {Object} data Other upload options, see action=upload API docs for more
270 * @return {jQuery.Promise}
271 */
272 uploadWithFormData: function ( file, data ) {
273 var key, request,
274 deferred = $.Deferred();
275
276 for ( key in data ) {
277 if ( !fieldsAllowed[ key ] ) {
278 delete data[ key ];
279 }
280 }
281
282 data = $.extend( {}, this.defaults.parameters, { action: 'upload' }, data );
283 if ( !data.chunk ) {
284 data.file = file;
285 }
286
287 if ( !data.filename && !data.stash ) {
288 throw new Error( 'Filename not included in file data.' );
289 }
290
291 // Use this.postWithEditToken() or this.post()
292 request = this[ this.needToken() ? 'postWithEditToken' : 'post' ]( data, {
293 // Use FormData (if we got here, we know that it's available)
294 contentType: 'multipart/form-data',
295 // No timeout (default from mw.Api is 30 seconds)
296 timeout: 0,
297 // Provide upload progress notifications
298 xhr: function () {
299 var xhr = $.ajaxSettings.xhr();
300 if ( xhr.upload ) {
301 // need to bind this event before we open the connection (see note at
302 // https://developer.mozilla.org/en-US/docs/DOM/XMLHttpRequest/Using_XMLHttpRequest#Monitoring_progress)
303 xhr.upload.addEventListener( 'progress', function ( ev ) {
304 if ( ev.lengthComputable ) {
305 deferred.notify( ev.loaded / ev.total );
306 }
307 } );
308 }
309 return xhr;
310 }
311 } )
312 .done( function ( result ) {
313 deferred.notify( 1 );
314 if ( result.upload && result.upload.warnings ) {
315 deferred.reject( getFirstKey( result.upload.warnings ), result );
316 } else {
317 deferred.resolve( result );
318 }
319 } )
320 .fail( function ( errorCode, result ) {
321 deferred.notify( 1 );
322 deferred.reject( errorCode, result );
323 } );
324
325 return deferred.promise( { abort: request.abort } );
326 },
327
328 /**
329 * Upload a file in several chunks.
330 *
331 * @param {File} file
332 * @param {Object} data Other upload options, see action=upload API docs for more
333 * @param {number} [chunkSize] Size (in bytes) per chunk (default: 5MB)
334 * @param {number} [chunkRetries] Amount of times to retry a failed chunk (default: 1)
335 * @return {jQuery.Promise}
336 */
337 chunkedUpload: function ( file, data, chunkSize, chunkRetries ) {
338 var start, end, promise, next, active,
339 deferred = $.Deferred();
340
341 chunkSize = chunkSize === undefined ? 5 * 1024 * 1024 : chunkSize;
342 chunkRetries = chunkRetries === undefined ? 1 : chunkRetries;
343
344 if ( !data.filename ) {
345 throw new Error( 'Filename not included in file data.' );
346 }
347
348 // Submit first chunk to get the filekey
349 active = promise = this.uploadChunk( file, data, 0, chunkSize, '', chunkRetries )
350 .done( chunkSize >= file.size ? deferred.resolve : null )
351 .fail( deferred.reject )
352 .progress( deferred.notify );
353
354 // Now iteratively submit the rest of the chunks
355 for ( start = chunkSize; start < file.size; start += chunkSize ) {
356 end = Math.min( start + chunkSize, file.size );
357 next = $.Deferred();
358
359 // We could simply chain one this.uploadChunk after another with
360 // .then(), but then we'd hit an `Uncaught RangeError: Maximum
361 // call stack size exceeded` at as low as 1024 calls in Firefox
362 // 47. This'll work around it, but comes with the drawback of
363 // having to properly relay the results to the returned promise.
364 // eslint-disable-next-line no-loop-func
365 promise.done( function ( start, end, next, result ) {
366 var filekey = result.upload.filekey;
367 active = this.uploadChunk( file, data, start, end, filekey, chunkRetries )
368 .done( end === file.size ? deferred.resolve : next.resolve )
369 .fail( deferred.reject )
370 .progress( deferred.notify );
371 // start, end & next must be bound to closure, or they'd have
372 // changed by the time the promises are resolved
373 }.bind( this, start, end, next ) );
374
375 promise = next;
376 }
377
378 return deferred.promise( { abort: active.abort } );
379 },
380
381 /**
382 * Uploads 1 chunk.
383 *
384 * @private
385 * @param {File} file
386 * @param {Object} data Other upload options, see action=upload API docs for more
387 * @param {number} start Chunk start position
388 * @param {number} end Chunk end position
389 * @param {string} [filekey] File key, for follow-up chunks
390 * @param {number} [retries] Amount of times to retry request
391 * @return {jQuery.Promise}
392 */
393 uploadChunk: function ( file, data, start, end, filekey, retries ) {
394 var upload,
395 api = this,
396 chunk = this.slice( file, start, end );
397
398 // When uploading in chunks, we're going to be issuing a lot more
399 // requests and there's always a chance of 1 getting dropped.
400 // In such case, it could be useful to try again: a network hickup
401 // doesn't necessarily have to result in upload failure...
402 retries = retries === undefined ? 1 : retries;
403
404 data.filesize = file.size;
405 data.chunk = chunk;
406 data.offset = start;
407
408 // filekey must only be added when uploading follow-up chunks; the
409 // first chunk should never have a filekey (it'll be generated)
410 if ( filekey && start !== 0 ) {
411 data.filekey = filekey;
412 }
413
414 upload = this.uploadWithFormData( file, data );
415 return upload.then(
416 null,
417 function ( code, result ) {
418 var retry;
419
420 // uploadWithFormData will reject uploads with warnings, but
421 // these warnings could be "harmless" or recovered from
422 // (e.g. exists-normalized, when it'll be renamed later)
423 // In the case of (only) a warning, we still want to
424 // continue the chunked upload until it completes: then
425 // reject it - at least it's been fully uploaded by then and
426 // failure handlers have a complete result object (including
427 // possibly more warnings, e.g. duplicate)
428 // This matches .upload, which also completes the upload.
429 if ( result.upload && result.upload.warnings && code in result.upload.warnings ) {
430 if ( end === file.size ) {
431 // uploaded last chunk = reject with result data
432 return $.Deferred().reject( code, result );
433 } else {
434 // still uploading chunks = resolve to keep going
435 return $.Deferred().resolve( result );
436 }
437 }
438
439 if ( retries === 0 ) {
440 return $.Deferred().reject( code, result );
441 }
442
443 // If the call flat out failed, we may want to try again...
444 retry = api.uploadChunk.bind( this, file, data, start, end, filekey, retries - 1 );
445 return api.retry( code, result, retry );
446 },
447 function ( fraction ) {
448 // Since we're only uploading small parts of a file, we
449 // need to adjust the reported progress to reflect where
450 // we actually are in the combined upload
451 return ( start + fraction * ( end - start ) ) / file.size;
452 }
453 ).promise( { abort: upload.abort } );
454 },
455
456 /**
457 * Launch the upload anew if it failed because of network issues.
458 *
459 * @private
460 * @param {string} code Error code
461 * @param {Object} result API result
462 * @param {Function} callable
463 * @return {jQuery.Promise}
464 */
465 retry: function ( code, result, callable ) {
466 var uploadPromise,
467 retryTimer,
468 deferred = $.Deferred(),
469 // Wrap around the callable, so that once it completes, it'll
470 // resolve/reject the promise we'll return
471 retry = function () {
472 uploadPromise = callable();
473 uploadPromise.then( deferred.resolve, deferred.reject );
474 };
475
476 // Don't retry if the request failed because we aborted it (or if
477 // it's another kind of request failure)
478 if ( code !== 'http' || result.textStatus === 'abort' ) {
479 return deferred.reject( code, result );
480 }
481
482 retryTimer = setTimeout( retry, 1000 );
483 return deferred.promise( { abort: function () {
484 // Clear the scheduled upload, or abort if already in flight
485 if ( retryTimer ) {
486 clearTimeout( retryTimer );
487 }
488 if ( uploadPromise.abort ) {
489 uploadPromise.abort();
490 }
491 } } );
492 },
493
494 /**
495 * Slice a chunk out of a File object.
496 *
497 * @private
498 * @param {File} file
499 * @param {number} start
500 * @param {number} stop
501 * @return {Blob}
502 */
503 slice: function ( file, start, stop ) {
504 if ( file.mozSlice ) {
505 // FF <= 12
506 return file.mozSlice( start, stop, file.type );
507 } else if ( file.webkitSlice ) {
508 // Chrome <= 20
509 return file.webkitSlice( start, stop, file.type );
510 } else {
511 // On really old browser versions (before slice was prefixed),
512 // slice() would take (start, length) instead of (start, end)
513 // We'll ignore that here...
514 return file.slice( start, stop, file.type );
515 }
516 },
517
518 /**
519 * This function will handle how uploads to stash (via uploadToStash or
520 * chunkedUploadToStash) are resolved/rejected.
521 *
522 * After a successful stash, it'll resolve with a callback which, when
523 * called, will finalize the upload in stash (with the given data, or
524 * with additional/conflicting data)
525 *
526 * A failed stash can still be recovered from as long as 'filekey' is
527 * present. In that case, it'll also resolve with the callback to
528 * finalize the upload (all warnings are then ignored.)
529 * Otherwise, it'll just reject as you'd expect, with code & result.
530 *
531 * @private
532 * @param {jQuery.Promise} uploadPromise
533 * @param {Object} data
534 * @return {jQuery.Promise}
535 * @return {Function} return.finishUpload Call this function to finish the upload.
536 * @return {Object} return.finishUpload.data Additional data for the upload.
537 * @return {jQuery.Promise} return.finishUpload.return API promise for the final upload
538 * @return {Object} return.finishUpload.return.data API return value for the final upload
539 */
540 finishUploadToStash: function ( uploadPromise, data ) {
541 var filekey,
542 api = this;
543
544 function finishUpload( moreData ) {
545 return api.uploadFromStash( filekey, $.extend( data, moreData ) );
546 }
547
548 return uploadPromise.then(
549 function ( result ) {
550 filekey = result.upload.filekey;
551 return finishUpload;
552 },
553 function ( errorCode, result ) {
554 if ( result && result.upload && result.upload.filekey ) {
555 // Ignore any warnings if 'filekey' was returned, that's all we care about
556 filekey = result.upload.filekey;
557 return $.Deferred().resolve( finishUpload );
558 }
559 return $.Deferred().reject( errorCode, result );
560 }
561 );
562 },
563
564 /**
565 * Upload a file to the stash.
566 *
567 * This function will return a promise, which when resolved, will pass back a function
568 * to finish the stash upload. You can call that function with an argument containing
569 * more, or conflicting, data to pass to the server. For example:
570 *
571 * // upload a file to the stash with a placeholder filename
572 * api.uploadToStash( file, { filename: 'testing.png' } ).done( function ( finish ) {
573 * // finish is now the function we can use to finalize the upload
574 * // pass it a new filename from user input to override the initial value
575 * finish( { filename: getFilenameFromUser() } ).done( function ( data ) {
576 * // the upload is complete, data holds the API response
577 * } );
578 * } );
579 *
580 * @param {File|HTMLInputElement} file
581 * @param {Object} [data]
582 * @return {jQuery.Promise}
583 * @return {Function} return.finishUpload Call this function to finish the upload.
584 * @return {Object} return.finishUpload.data Additional data for the upload.
585 * @return {jQuery.Promise} return.finishUpload.return API promise for the final upload
586 * @return {Object} return.finishUpload.return.data API return value for the final upload
587 */
588 uploadToStash: function ( file, data ) {
589 var promise;
590
591 if ( !data.filename ) {
592 throw new Error( 'Filename not included in file data.' );
593 }
594
595 promise = this.upload( file, { stash: true, filename: data.filename } );
596
597 return this.finishUploadToStash( promise, data );
598 },
599
600 /**
601 * Upload a file to the stash, in chunks.
602 *
603 * This function will return a promise, which when resolved, will pass back a function
604 * to finish the stash upload.
605 *
606 * @see #method-uploadToStash
607 * @param {File|HTMLInputElement} file
608 * @param {Object} [data]
609 * @param {number} [chunkSize] Size (in bytes) per chunk (default: 5MB)
610 * @param {number} [chunkRetries] Amount of times to retry a failed chunk (default: 1)
611 * @return {jQuery.Promise}
612 * @return {Function} return.finishUpload Call this function to finish the upload.
613 * @return {Object} return.finishUpload.data Additional data for the upload.
614 * @return {jQuery.Promise} return.finishUpload.return API promise for the final upload
615 * @return {Object} return.finishUpload.return.data API return value for the final upload
616 */
617 chunkedUploadToStash: function ( file, data, chunkSize, chunkRetries ) {
618 var promise;
619
620 if ( !data.filename ) {
621 throw new Error( 'Filename not included in file data.' );
622 }
623
624 promise = this.chunkedUpload(
625 file,
626 { stash: true, filename: data.filename },
627 chunkSize,
628 chunkRetries
629 );
630
631 return this.finishUploadToStash( promise, data );
632 },
633
634 /**
635 * Finish an upload in the stash.
636 *
637 * @param {string} filekey
638 * @param {Object} data
639 * @return {jQuery.Promise}
640 */
641 uploadFromStash: function ( filekey, data ) {
642 data.filekey = filekey;
643 data.action = 'upload';
644 data.format = 'json';
645
646 if ( !data.filename ) {
647 throw new Error( 'Filename not included in file data.' );
648 }
649
650 return this.postWithEditToken( data ).then( function ( result ) {
651 if ( result.upload && result.upload.warnings ) {
652 return $.Deferred().reject( getFirstKey( result.upload.warnings ), result ).promise();
653 }
654 return result;
655 } );
656 },
657
658 needToken: function () {
659 return true;
660 }
661 } );
662
663 /**
664 * @class mw.Api
665 * @mixins mw.Api.plugin.upload
666 */
667 }() );