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