diff --git a/package-lock.json b/package-lock.json index 585ab1bc4..91e0ba871 100644 --- a/package-lock.json +++ b/package-lock.json @@ -69,6 +69,11 @@ "resolved": "https://registry.npmjs.org/@ionic-native/file/-/file-4.3.3.tgz", "integrity": "sha512-r273zw1gkgGTmlapyJnh31Yemt1P8u1CnTtiZGr3oC/BdlSvLppR7ONW7KbsAxA31UIDAXG+mXP3EqEP2AtVvw==" }, + "@ionic-native/file-transfer": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@ionic-native/file-transfer/-/file-transfer-4.3.3.tgz", + "integrity": "sha512-NRs2Nl2+Zzk4tVXESssV9DcYKvzmgUn6e1+MwxJ+QmVoQNmeSPNRnFSQoqE+IEkCjENjK1Yg0XJFfcDBpyCp6Q==" + }, "@ionic-native/globalization": { "version": "4.3.2", "resolved": "https://registry.npmjs.org/@ionic-native/globalization/-/globalization-4.3.2.tgz", @@ -4707,6 +4712,11 @@ "integrity": "sha1-WIeWa7WCpFA6QetST301ARgVphM=", "dev": true }, + "ts-md5": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/ts-md5/-/ts-md5-1.2.2.tgz", + "integrity": "sha512-rTjX9/xTOvAcKC7TIsahiSdeyXgMtKa4wd6iClduCWeVoj41Q/HX5oYLkJzHQktW/DHbBjUatoFj0Q481N/Idw==" + }, "tsickle": { "version": "0.21.6", "resolved": "https://registry.npmjs.org/tsickle/-/tsickle-0.21.6.tgz", diff --git a/package.json b/package.json index a7c44e25b..0d3753420 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "@ionic-native/clipboard": "^4.3.2", "@ionic-native/core": "4.3.0", "@ionic-native/file": "^4.3.3", + "@ionic-native/file-transfer": "^4.3.3", "@ionic-native/globalization": "^4.3.2", "@ionic-native/in-app-browser": "^4.3.3", "@ionic-native/keyboard": "^4.3.2", @@ -63,6 +64,7 @@ "promise.prototype.finally": "^3.0.1", "rxjs": "5.4.3", "sw-toolbox": "3.6.0", + "ts-md5": "^1.2.2", "zone.js": "0.8.18" }, "devDependencies": { diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 323c0a050..bbb6d8f17 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -25,6 +25,7 @@ import { CoreUtilsProvider } from '../providers/utils/utils'; import { CoreMimetypeUtilsProvider } from '../providers/utils/mimetype'; import { CoreInitDelegate } from '../providers/init'; import { CoreFileProvider } from '../providers/file'; +import { CoreWSProvider } from '../providers/ws'; // For translate loader. AoT requires an exported function for factories. export function createTranslateLoader(http: HttpClient) { @@ -70,7 +71,8 @@ export function createTranslateLoader(http: HttpClient) { CoreUtilsProvider, CoreMimetypeUtilsProvider, CoreInitDelegate, - CoreFileProvider + CoreFileProvider, + CoreWSProvider ] }) export class AppModule { diff --git a/src/core/constants.ts b/src/core/constants.ts index 08b6ef10b..f596d8acd 100644 --- a/src/core/constants.ts +++ b/src/core/constants.ts @@ -24,4 +24,5 @@ export class CoreConstants { public static downloadThreshold = 10485760; // 10MB. public static dontShowError = 'CoreDontShowError'; public static settingsRichTextEditor = 'CoreSettingsRichTextEditor'; + public static wsTimeout = 30000; } diff --git a/src/core/emulator/emulator.module.ts b/src/core/emulator/emulator.module.ts index 5a831d0d0..d1798a351 100644 --- a/src/core/emulator/emulator.module.ts +++ b/src/core/emulator/emulator.module.ts @@ -17,11 +17,14 @@ import { Platform } from 'ionic-angular'; import { Clipboard } from '@ionic-native/clipboard'; import { File } from '@ionic-native/file'; +import { FileTransfer } from '@ionic-native/file-transfer'; import { Globalization } from '@ionic-native/globalization'; import { Network } from '@ionic-native/network'; import { Zip } from '@ionic-native/zip'; + import { ClipboardMock } from './providers/clipboard'; -import { FileMock} from './providers/file'; +import { FileMock } from './providers/file'; +import { FileTransferMock } from './providers/file-transfer'; import { GlobalizationMock } from './providers/globalization'; import { NetworkMock } from './providers/network'; import { ZipMock } from './providers/zip'; @@ -29,6 +32,7 @@ import { InAppBrowser } from '@ionic-native/in-app-browser'; import { CoreEmulatorHelper } from './providers/helper'; import { CoreAppProvider } from '../../providers/app'; +import { CoreFileProvider } from '../../providers/file'; import { CoreTextUtilsProvider } from '../../providers/utils/text'; import { CoreMimetypeUtilsProvider } from '../../providers/utils/mimetype'; import { CoreInitDelegate } from '../../providers/init'; @@ -57,6 +61,14 @@ import { CoreInitDelegate } from '../../providers/init'; return appProvider.isMobile() ? new File() : new FileMock(appProvider, textUtils); } }, + { + provide: FileTransfer, + deps: [CoreAppProvider, CoreFileProvider], + useFactory: (appProvider: CoreAppProvider, fileProvider: CoreFileProvider) => { + // Use platform instead of CoreAppProvider to prevent circular dependencies. + return appProvider.isMobile() ? new FileTransfer() : new FileTransferMock(appProvider, fileProvider); + } + }, { provide: Globalization, deps: [CoreAppProvider], diff --git a/src/core/emulator/providers/file-transfer.ts b/src/core/emulator/providers/file-transfer.ts new file mode 100644 index 000000000..4ce1da533 --- /dev/null +++ b/src/core/emulator/providers/file-transfer.ts @@ -0,0 +1,336 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; +import { FileTransfer, FileTransferObject, FileUploadResult, FileTransferError } from '@ionic-native/file-transfer'; +import { CoreAppProvider } from '../../../providers/app'; +import { CoreFileProvider } from '../../../providers/file'; + +/** + * Mock the File Transfer Error. + */ +export class FileTransferErrorMock implements FileTransferError { + public static FILE_NOT_FOUND_ERR = 1; + public static INVALID_URL_ERR = 2; + public static CONNECTION_ERR = 3; + public static ABORT_ERR = 4; + public static NOT_MODIFIED_ERR = 5; + + constructor(public code: number, public source: string, public target: string, public http_status: number, + public body: string, public exception: string) { + } +}; + +/** + * Emulates the Cordova FileTransfer plugin in desktop apps and in browser. + */ +@Injectable() +export class FileTransferMock extends FileTransfer { + + constructor(private appProvider: CoreAppProvider, private fileProvider: CoreFileProvider) { + super(); + } + + /** + * Creates a new FileTransferObjectMock object. + * + * @return {FileTransferObjectMock} + */ + create(): FileTransferObjectMock { + return new FileTransferObjectMock(this.appProvider, this.fileProvider); + } +} + +/** + * Emulates the FileTransferObject class in desktop apps and in browser. + */ +export class FileTransferObjectMock extends FileTransferObject { + progressListener: (event: ProgressEvent) => any; + source: string; + target: string; + xhr: XMLHttpRequest; + private reject: Function; + + constructor(private appProvider: CoreAppProvider, private fileProvider: CoreFileProvider) { + super(); + } + + /** + * Aborts an in-progress transfer. The onerror callback is passed a FileTransferError + * object which has an error code of FileTransferError.ABORT_ERR. + */ + abort(): void { + if (this.xhr) { + this.xhr.abort(); + this.reject(new FileTransferErrorMock(FileTransferErrorMock.ABORT_ERR, this.source, this.target, null, null, null)); + } + } + + /** + * Downloads a file from server. + * + * @param {string} source URL of the server to download the file, as encoded by encodeURI(). + * @param {string} target Filesystem url representing the file on the device. + * @param {boolean} [trustAllHosts] If set to true, it accepts all security certificates. + * @param {object} [options] Optional parameters, currently only supports headers. + * @returns {Promise} Returns a Promise that resolves to a FileEntry object. + */ + download(source: string, target: string, trustAllHosts?: boolean, options?: { [s: string]: any; }): Promise { + return new Promise((resolve, reject) => { + // Use XMLHttpRequest instead of HttpClient to support onprogress and abort. + let basicAuthHeader = this.getBasicAuthHeader(source), + xhr = new XMLHttpRequest(), + isDesktop = this.appProvider.isDesktop(), + headers = null; + + this.xhr = xhr; + this.source = source; + this.target = target; + this.reject = reject; + + if (basicAuthHeader) { + source = source.replace(this.getUrlCredentials(source) + '@', ''); + + options = options || {}; + options.headers = options.headers || {}; + options.headers[basicAuthHeader.name] = basicAuthHeader.value; + } + + if (options) { + headers = options.headers || null; + } + + // Prepare the request. + xhr.open('GET', source, true); + xhr.responseType = isDesktop ? 'arraybuffer' : 'blob'; + for (let name in headers) { + xhr.setRequestHeader(name, headers[name]); + } + + (xhr).onprogress = (xhr, ev) => { + if (this.progressListener) { + this.progressListener(ev); + } + }; + + xhr.onerror = (err) => { + reject(new FileTransferError(-1, source, target, xhr.status, xhr.statusText)); + }; + + xhr.onload = () => { + // Finished dowloading the file. + let response = xhr.response; + if (!response) { + reject(); + } else { + const basePath = this.fileProvider.getBasePathInstant(); + target = target.replace(basePath, ''); // Remove basePath from the target. + target = target.replace(/%20/g, ' '); // Replace all %20 with spaces. + if (isDesktop) { + // In desktop we need to convert the arraybuffer into a Buffer. + response = Buffer.from( new Uint8Array(response)); + } + + this.fileProvider.writeFile(target, response).then(resolve, reject); + } + }; + + xhr.send(); + }); + } + + /** + * Given a URL, check if it has a credentials in it and, if so, return them in a header object. + * This code is extracted from Cordova FileTransfer plugin. + * + * @param {string} urlString The URL to get the credentials from. + * @return {any} The header with the credentials, null if no credentials. + */ + protected getBasicAuthHeader(urlString: string): any { + let header = null; + + // This is changed due to MS Windows doesn't support credentials in http uris + // so we detect them by regexp and strip off from result url. + if (window.btoa) { + const credentials = this.getUrlCredentials(urlString); + if (credentials) { + const authHeader = 'Authorization', + authHeaderValue = 'Basic ' + window.btoa(credentials); + + header = { + name: authHeader, + value: authHeaderValue + }; + } + } + + return header; + } + + /** + * Given an instance of XMLHttpRequest, get the response headers as an object. + * + * @param {XMLHttpRequest} xhr XMLHttpRequest instance. + * @return {{[s: string]: any}} Object with the headers. + */ + protected getHeadersAsObject(xhr: XMLHttpRequest) : { [s: string]: any } { + const headersString = xhr.getAllResponseHeaders(); + let result = {}; + + if (headersString) { + const headers = headersString.split('\n'); + for (let i in headers) { + const headerString = headers[i], + separatorPos = headerString.indexOf(':'); + if (separatorPos != -1) { + result[headerString.substr(0, separatorPos)] = headerString.substr(separatorPos + 1).trim(); + } + } + } + return result; + } + + /** + * Get the credentials from a URL. + * This code is extracted from Cordova FileTransfer plugin. + * + * @param {string} urlString The URL to get the credentials from. + * @return {string} Retrieved credentials. + */ + protected getUrlCredentials(urlString: string) : string { + const credentialsPattern = /^https?\:\/\/(?:(?:(([^:@\/]*)(?::([^@\/]*))?)?@)?([^:\/?#]*)(?::(\d*))?).*$/, + credentials = credentialsPattern.exec(urlString); + + return credentials && credentials[1]; + } + + /** + * Registers a listener that gets called whenever a new chunk of data is transferred. + * + * @param {Function} listener Listener that takes a progress event. + */ + onProgress(listener: (event: ProgressEvent) => any): void { + this.progressListener = listener; + } + + /** + * Sends a file to a server. + * + * @param {string} fileUrl Filesystem URL representing the file on the device or a data URI. + * @param {string} url URL of the server to receive the file, as encoded by encodeURI(). + * @param {FileUploadOptions} [options] Optional parameters. + * @param {boolean} [trustAllHosts] If set to true, it accepts all security certificates. + * @returns {Promise} Promise that resolves to a FileUploadResult and rejects with FileTransferError. + */ + upload(fileUrl: string, url: string, options?: FileUploadOptions, trustAllHosts?: boolean): Promise { + return new Promise((resolve, reject) => { + let fileKey = null, + fileName = null, + mimeType = null, + params = null, + headers = null, + httpMethod = null, + basicAuthHeader = this.getBasicAuthHeader(url); + + if (basicAuthHeader) { + url = url.replace(this.getUrlCredentials(url) + '@', ''); + + options = options || {}; + options.headers = options.headers || {}; + options.headers[basicAuthHeader.name] = basicAuthHeader.value; + } + + if (options) { + fileKey = options.fileKey; + fileName = options.fileName; + mimeType = options.mimeType; + headers = options.headers; + httpMethod = options.httpMethod || 'POST'; + + if (httpMethod.toUpperCase() == "PUT"){ + httpMethod = 'PUT'; + } else { + httpMethod = 'POST'; + } + + if (options.params) { + params = options.params; + } else { + params = {}; + } + } + + // Add fileKey and fileName to the headers. + headers = headers || {}; + if (!headers['Content-Disposition']) { + headers['Content-Disposition'] = 'form-data;' + (fileKey ? ' name="' + fileKey + '";' : '') + + (fileName ? ' filename="' + fileName + '"' : '') + } + + // For some reason, adding a Content-Type header with the mimeType makes the request fail (it doesn't detect + // the token in the params). Don't include this header, and delete it if it's supplied. + delete headers['Content-Type']; + + // Get the file to upload. + this.fileProvider.getFile(fileUrl).then((fileEntry) => { + return this.fileProvider.getFileObjectFromFileEntry(fileEntry); + }).then((file) => { + // Use XMLHttpRequest instead of HttpClient to support onprogress and abort. + let xhr = new XMLHttpRequest(); + xhr.open(httpMethod || 'POST', url); + for (let name in headers) { + // Filter "unsafe" headers. + if (name != 'Connection') { + xhr.setRequestHeader(name, headers[name]); + } + } + + (xhr).onprogress = (xhr, ev) => { + if (this.progressListener) { + this.progressListener(ev); + } + }; + + this.xhr = xhr; + this.source = fileUrl; + this.target = url; + this.reject = reject; + + xhr.onerror = () => { + reject(new FileTransferError(-1, fileUrl, url, xhr.status, xhr.statusText)); + }; + + xhr.onload = () => { + // Finished uploading the file. + let result: FileUploadResult = { + bytesSent: file.size, + responseCode: xhr.status, + response: xhr.response, + headers: this.getHeadersAsObject(xhr) + }; + resolve(result); + }; + + // Create a form data to send params and the file. + let fd = new FormData(); + for (var name in params) { + fd.append(name, params[name]); + } + fd.append('file', file); + + xhr.send(fd); + }).catch(reject); + }); + } +} diff --git a/src/core/emulator/providers/helper.ts b/src/core/emulator/providers/helper.ts index 96780ca86..1a0322a7c 100644 --- a/src/core/emulator/providers/helper.ts +++ b/src/core/emulator/providers/helper.ts @@ -17,6 +17,7 @@ import { CoreFileProvider } from '../../../providers/file'; import { CoreUtilsProvider } from '../../../providers/utils/utils'; import { File } from '@ionic-native/file'; import { CoreInitDelegate, CoreInitHandler } from '../../../providers/init'; +import { FileTransferErrorMock } from './file-transfer'; /** * Emulates the Cordova Zip plugin in desktop apps and in browser. @@ -43,6 +44,7 @@ export class CoreEmulatorHelper implements CoreInitHandler { promises.push((this.file).load().then((basePath: string) => { this.fileProvider.setHTMLBasePath(basePath); })); + (window).FileTransferError = FileTransferErrorMock; return this.utils.allPromises(promises); } diff --git a/src/providers/ws.ts b/src/providers/ws.ts new file mode 100644 index 000000000..ed0893775 --- /dev/null +++ b/src/providers/ws.ts @@ -0,0 +1,719 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { TranslateService } from '@ngx-translate/core'; +import { FileTransfer, FileUploadOptions } from '@ionic-native/file-transfer'; +import { CoreAppProvider } from './app'; +import { CoreFileProvider } from './file'; +import { CoreLoggerProvider } from './logger'; +import { CoreMimetypeUtilsProvider } from './utils/mimetype'; +import { CoreTextUtilsProvider } from './utils/text'; +import { CoreUtilsProvider } from './utils/utils'; +import { CoreConstants } from '../core/constants'; +import { Md5 } from 'ts-md5/dist/md5'; + +/** + * Interface of the presets accepted by the WS call. + */ +export interface CoreWSPreSets { + siteUrl: string; // The site URL. + wsToken: string; // The Webservice token. + responseExpected?: boolean; // Defaults to true. Set to false when the expected response is null. + typeExpected?: string; // Defaults to 'object'. Use it when you expect a type that's not an object|array. + cleanUnicode?: boolean; // Defaults to false. Clean multibyte Unicode chars from data. +}; + +/** + * Interface of the presets accepted by AJAX WS calls. + */ +export interface CoreWSAjaxPreSets { + siteUrl: string; // The site URL. + responseExpected?: boolean; // Defaults to true. Set to false when the expected response is null. +}; + +/** + * Interface for WS Errors. + */ +export interface CoreWSError { + message: string; // The error message. + exception?: string; // Name of the exception. Undefined for local errors (fake WS errors). + errorcode?: string; // The error code. Undefined for local errors (fake WS errors). +}; + +/** + * Interface for file upload options. + */ +export interface CoreWSFileUploadOptions extends FileUploadOptions { + fileArea?: string; // The file area where to put the file. By default, 'draft'. + itemId?: number; // Item ID of the area where to put the file. By default, 0. +}; + +/** + * This service allows performing WS calls and download/upload files. + */ +@Injectable() +export class CoreWSProvider { + logger; + mimeTypeCache = {}; // A "cache" to store file mimetypes to prevent performing too many HEAD requests. + ongoingCalls = {}; + retryCalls = []; + retryTimeout = 0; + + constructor(private http: HttpClient, private translate: TranslateService, private appProvider: CoreAppProvider, + private textUtils: CoreTextUtilsProvider, logger: CoreLoggerProvider, private utils: CoreUtilsProvider, + private fileProvider: CoreFileProvider, private fileTransfer: FileTransfer, private mimeUtils: CoreMimetypeUtilsProvider) { + this.logger = logger.getInstance('CoreWSProvider'); + } + + /** + * Adds the call data to an special queue to be processed when retrying. + * + * @param {string} method The WebService method to be called. + * @param {string} siteUrl Complete site url to perform the call. + * @param {any} ajaxData Arguments to pass to the method. + * @param {CoreWSPreSets} preSets Extra settings and information. + * @return {Promise} Deferrend promise resolved with the response data in success and rejected with the error message if it fails. + */ + protected addToRetryQueue(method: string, siteUrl: string, ajaxData: any, preSets: CoreWSPreSets) { + let call = { + method: method, + siteUrl: siteUrl, + ajaxData: ajaxData, + preSets: preSets, + deferred: this.utils.promiseDefer(), + }; + + this.retryCalls.push(call); + return call.deferred.promise; + } + + /** + * A wrapper function for a moodle WebService call. + * + * @param {string} method The WebService method to be called. + * @param {any} data Arguments to pass to the method. + * @param {CoreWSPreSets} preSets Extra settings and information. + * @return {Promise} Promise resolved with the response data in success and rejected with the error message if it fails. + */ + call(method: string, data: any, preSets: CoreWSPreSets) { + + let siteUrl; + + if (!preSets) { + return Promise.reject(this.createFakeWSError('mm.core.unexpectederror', true)); + } else if (!this.appProvider.isOnline()) { + return Promise.reject(this.createFakeWSError('mm.core.networkerrormsg', true)); + } + + preSets.typeExpected = preSets.typeExpected || 'object'; + if (typeof preSets.responseExpected == 'undefined') { + preSets.responseExpected = true; + } + + try { + data = this.convertValuesToString(data, preSets.cleanUnicode); + } catch (e) { + // Empty cleaned text found. + return Promise.reject(this.createFakeWSError('mm.core.unicodenotsupportedcleanerror', true)); + } + + data.wsfunction = method; + data.wstoken = preSets.wsToken; + siteUrl = preSets.siteUrl + '/webservice/rest/server.php?moodlewsrestformat=json'; + + let promise = this.getPromiseHttp('post', preSets.siteUrl, data); + + if (!promise) { + // There are some ongoing retry calls, wait for timeout. + if (this.retryCalls.length > 0) { + this.logger.warn('Calls locked, trying later...'); + promise = this.addToRetryQueue(method, siteUrl, data, preSets); + } else { + promise = this.performPost(method, siteUrl, data, preSets); + } + } + + return promise; + } + + /** + * Call a Moodle WS using the AJAX API. Please use it if the WS layer is not an option. + * + * @param {string} method The WebService method to be called. + * @param {any} data Arguments to pass to the method. + * @param {CoreWSAjaxPreSets} preSets Extra settings and information. Only some + * @return {Promise} Promise resolved with the response data in success and rejected with an object containing: + * - error: Error message. + * - errorcode: Error code returned by the site (if any). + * - available: 0 if unknown, 1 if available, -1 if not available. + */ + callAjax(method: string, data: any, preSets: CoreWSAjaxPreSets) { + let siteUrl, + ajaxData; + + if (typeof preSets.siteUrl == 'undefined') { + return rejectWithError(this.translate.instant('mm.core.unexpectederror')); + } else if (!this.appProvider.isOnline()) { + return rejectWithError(this.translate.instant('mm.core.networkerrormsg')); + } + + if (typeof preSets.responseExpected == 'undefined') { + preSets.responseExpected = true; + } + + ajaxData = [{ + index: 0, + methodname: method, + args: this.convertValuesToString(data) + }]; + + siteUrl = preSets.siteUrl + '/lib/ajax/service.php'; + + return new Promise((resolve, reject) => { + this.http.post(siteUrl, JSON.stringify(ajaxData)).timeout(CoreConstants.wsTimeout).subscribe((data: any) => { + // Some moodle web services return null. If the responseExpected value is set then so long as no data + // is returned, we create a blank object. + if (!data && !preSets.responseExpected) { + data = [{}]; + } + + // Check if error. Ajax layer should always return an object (if error) or an array (if success). + if (!data || typeof data != 'object') { + return rejectWithError(this.translate.instant('mm.core.serverconnection')); + } else if (data.error) { + return rejectWithError(data.error, data.errorcode); + } + + // Get the first response since only one request was done. + data = data[0]; + + if (data.error) { + return rejectWithError(data.exception.message, data.exception.errorcode); + } + + return data.data; + }, (data) => { + let available = data.status == 404 ? -1 : 0; + return rejectWithError(this.translate.instant('mm.core.serverconnection'), '', available); + }); + }); + + // Convenience function to return an error. + function rejectWithError(message: string, code?: string, available?: number) { + if (typeof available == 'undefined') { + if (code) { + available = code == 'invalidrecord' ? -1 : 1; + } else { + available = 0; + } + } + + return Promise.reject({ + error: message, + errorcode: code, + available: available + }); + } + } + + /** + * Converts an objects values to strings where appropriate. + * Arrays (associative or otherwise) will be maintained. + * + * @param {object} data The data that needs all the non-object values set to strings. + * @param {boolean} [stripUnicode] If Unicode long chars need to be stripped. + * @return {object} The cleaned object, with multilevel array and objects preserved. + */ + protected convertValuesToString(data: object, stripUnicode?: boolean) : object { + let result; + if (!Array.isArray(data) && typeof data == 'object') { + result = {}; + } else { + result = []; + } + + for (let el in data) { + if (typeof data[el] == 'object') { + result[el] = this.convertValuesToString(data[el], stripUnicode); + } else { + if (typeof data[el] == 'string') { + result[el] = stripUnicode ? this.textUtils.stripUnicode(data[el]) : data[el]; + if (stripUnicode && data[el] != result[el] && result[el].trim().length == 0) { + throw new Error(); + } + } else { + result[el] = data[el] + ''; + } + } + } + return result; + } + + /** + * Create a "fake" WS error for local errors. + * + * @param {string} message The message to include in the error. + * @param {boolean} [needsTranslate] If the message needs to be translated. + * @return {CoreWSError} Fake WS error. + */ + createFakeWSError(message: string, needsTranslate?: boolean) : CoreWSError { + if (needsTranslate) { + message = this.translate.instant(message); + } + return { + message: message + }; + } + + /** + * Downloads a file from Moodle using Cordova File API. + * + * @param {string} url Download url. + * @param {string} path Local path to store the file. + * @param {boolean} addExtension True if extension need to be added to the final path. + * @return {Promise} Promise resolved with the downloaded file. + */ + downloadFile(url: string, path: string, addExtension?: boolean) : Promise { + this.logger.debug('Downloading file', url, path, addExtension); + + if (!this.appProvider.isOnline()) { + return Promise.reject(this.translate.instant('mm.core.networkerrormsg')); + } + + // Use a tmp path to download the file and then move it to final location. This is because if the download fails, + // the local file is deleted. + let tmpPath = path + '.tmp'; + + // Create the tmp file as an empty file. + return this.fileProvider.createFile(tmpPath).then((fileEntry) => { + let transfer = this.fileTransfer.create(); + return transfer.download(url, fileEntry.toURL(), true).then(() => { + let promise; + + if (addExtension) { + let ext = this.mimeUtils.getFileExtension(path); + + // Google Drive extensions will be considered invalid since Moodle usually converts them. + if (!ext || ext == 'gdoc' || ext == 'gsheet' || ext == 'gslides' || ext == 'gdraw') { + // Not valid, get the file's mimetype. + promise = this.getRemoteFileMimeType(url).then((mime) => { + if (mime) { + let remoteExt = this.mimeUtils.getExtension(mime, url); + // If the file is from Google Drive, ignore mimetype application/json (sometimes pluginfile + // returns an invalid mimetype for files). + if (remoteExt && (!ext || mime != 'application/json')) { + if (ext) { + // Remove existing extension since we will use another one. + path = this.mimeUtils.removeExtension(path); + } + path += '.' + remoteExt; + return remoteExt; + } + } + return ext; + }); + } else { + promise = Promise.resolve(ext); + } + } else { + promise = Promise.resolve(''); + } + + return promise.then((extension) => { + return this.fileProvider.moveFile(tmpPath, path).then((movedEntry) => { + // Save the extension. + movedEntry.extension = extension; + movedEntry.path = path; + this.logger.debug(`Success downloading file ${url} to ${path} with extension ${extension}`); + return movedEntry; + }); + }); + }); + }).catch((err) => { + this.logger.error(`Error downloading ${url} to ${path}`, JSON.stringify(err)); + return Promise.reject(err); + }); + } + + /** + * Get a promise from the cache. + * + * @param {string} method Method of the HTTP request. + * @param {string} url Base URL of the HTTP request. + * @param {any} [params] Params of the HTTP request. + */ + protected getPromiseHttp(method: string, url: string, params?: any) : any { + let queueItemId = this.getQueueItemId(method, url, params); + if (typeof this.ongoingCalls[queueItemId] != 'undefined') { + return this.ongoingCalls[queueItemId]; + } + + return false; + } + + /** + * Perform a HEAD request to get the mimetype of a remote file. + * + * @param {string} url File URL. + * @param {boolean} [ignoreCache] True to ignore cache, false otherwise. + * @return {Promise} Promise resolved with the mimetype or '' if failure. + */ + getRemoteFileMimeType(url: string, ignoreCache?: boolean) : Promise { + if (this.mimeTypeCache[url] && !ignoreCache) { + return Promise.resolve(this.mimeTypeCache[url]); + } + + return this.performHead(url).then((data) => { + let mimeType = data.headers('Content-Type'); + if (mimeType) { + // Remove "parameters" like charset. + mimeType = mimeType.split(';')[0]; + } + this.mimeTypeCache[url] = mimeType; + + return mimeType || ''; + }).catch(() => { + // Error, resolve with empty mimetype. + return ''; + }); + } + + /** + * Perform a HEAD request to get the size of a remote file. + * + * @param {string} url File URL. + * @return {Promise} Promise resolved with the size or -1 if failure. + */ + getRemoteFileSize(url: string) : Promise { + return this.performHead(url).then((data) => { + let size = parseInt(data.headers('Content-Length'), 10); + + if (size) { + return size; + } + return -1; + }).catch(() => { + // Error, return -1. + return -1; + }); + } + + /** + * Get the unique queue item id of the cache for a HTTP request. + * + * @param {string} method Method of the HTTP request. + * @param {string} url Base URL of the HTTP request. + * @param {object} [params] Params of the HTTP request. + */ + protected getQueueItemId(method: string, url: string, params?: any) { + if (params) { + url += '###' + this.utils.serialize(params); + } + return method + '#' + Md5.hashAsciiStr(url); + } + + /** + * Perform a HEAD request and save the promise while waiting to be resolved. + * + * @param {string} url URL to perform the request. + * @return {Promise} Promise resolved with the response. + */ + performHead(url: string) : Promise { + let promise = this.getPromiseHttp('head', url); + + if (!promise) { + promise = new Promise((resolve, reject) => { + this.http.head(url).timeout(CoreConstants.wsTimeout).subscribe(resolve, reject); + }); + + this.setPromiseHttp(promise, 'head', url); + } + + return promise; + } + + /** + * Perform the post call and save the promise while waiting to be resolved. + * + * @param {string} method The WebService method to be called. + * @param {string} siteUrl Complete site url to perform the call. + * @param {any} ajaxData Arguments to pass to the method. + * @param {CoreWSPreSets} preSets Extra settings and information. + * @return {Promise} Promise resolved with the response data in success and rejected with CoreWSError if it fails. + */ + performPost(method: string, siteUrl: string, ajaxData: any, preSets: CoreWSPreSets) : Promise { + // Create the promise for the request. + let promise = new Promise((resolve, reject) => { + + this.http.post(siteUrl, ajaxData).timeout(CoreConstants.wsTimeout).subscribe((data: any) => { + // Some moodle web services return null. + // If the responseExpected value is set to false, we create a blank object if the response is null. + if (!data && !preSets.responseExpected) { + data = {}; + } + + if (!data) { + return Promise.reject(this.createFakeWSError('mm.core.serverconnection', true)); + } else if (typeof data != preSets.typeExpected) { + this.logger.warn('Response of type "' + typeof data + `" received, expecting "${preSets.typeExpected}"`); + return Promise.reject(this.createFakeWSError('mm.core.errorinvalidresponse', true)); + } + + if (typeof data.exception !== 'undefined') { + return Promise.reject(data); + } + + if (typeof data.debuginfo != 'undefined') { + return Promise.reject(this.createFakeWSError('Error. ' + data.message)); + } + + return data; + }, (error) => { + // If server has heavy load, retry after some seconds. + if (error.status == 429) { + let retryPromise = this.addToRetryQueue(method, siteUrl, ajaxData, preSets); + + // Only process the queue one time. + if (this.retryTimeout == 0) { + this.retryTimeout = parseInt(error.headers('Retry-After'), 10) || 5; + this.logger.warn(`${error.statusText}. Retrying in ${this.retryTimeout} seconds. ` + + `${this.retryCalls.length} calls left.`); + + setTimeout(() => { + this.logger.warn(`Retrying now with ${this.retryCalls.length} calls to process.`); + // Finish timeout. + this.retryTimeout = 0; + this.processRetryQueue(); + }, this.retryTimeout * 1000); + } else { + this.logger.warn('Calls locked, trying later...'); + } + + return retryPromise; + } + + return Promise.reject(this.createFakeWSError('mm.core.serverconnection', true)); + }); + }); + + this.setPromiseHttp(promise, 'post', preSets.siteUrl, ajaxData); + + return promise; + } + + /** + * Retry all requests in the queue. + * This function uses recursion in order to add a delay between requests to reduce stress. + */ + protected processRetryQueue() : void { + if (this.retryCalls.length > 0 && this.retryTimeout == 0) { + let call = this.retryCalls.shift(); + // Add a delay between calls. + setTimeout(() => { + call.deferred.resolve(this.performPost(call.method, call.siteUrl, call.ajaxData, call.preSets)); + this.processRetryQueue(); + }, 200); + } else { + this.logger.warn(`Retry queue has stopped with ${this.retryCalls.length} calls and ${this.retryTimeout} timeout secs.`); + } + } + + /** + * Save promise on the cache. + * + * @param {Promise} promise Promise to be saved. + * @param {string} method Method of the HTTP request. + * @param {string} url Base URL of the HTTP request. + * @param {any} [params] Params of the HTTP request. + */ + protected setPromiseHttp(promise: Promise, method: string, url: string, params?: any) : void { + let timeout, + queueItemId = this.getQueueItemId(method, url, params); + + this.ongoingCalls[queueItemId] = promise; + + // HTTP not finished, but we should delete the promise after timeout. + timeout = setTimeout(() => { + delete this.ongoingCalls[queueItemId]; + }, CoreConstants.wsTimeout); + + // HTTP finished, delete from ongoing. + this.ongoingCalls[queueItemId].finally(() => { + delete this.ongoingCalls[queueItemId]; + + clearTimeout(timeout); + }); + } + + /** + * A wrapper function for a synchronous Moodle WebService call. + * Warning: This function should only be used if synchronous is a must. It's recommended to use call. + * + * @param {string} method The WebService method to be called. + * @param {any} data Arguments to pass to the method. + * @param {CoreWSPreSets} preSets Extra settings and information. + * @return {Promise} Promise resolved with the response data in success and rejected with the error message if it fails. + * @return {any} Request response. If the request fails, returns an object with 'error'=true and 'message' properties. + */ + syncCall(method: string, data: any, preSets: CoreWSPreSets) : any { + let siteUrl, + xhr, + errorResponse = { + error: true, + message: '' + }; + + if (!preSets) { + errorResponse.message = this.translate.instant('mm.core.unexpectederror'); + return errorResponse; + } else if (!this.appProvider.isOnline()) { + errorResponse.message = this.translate.instant('mm.core.networkerrormsg'); + return errorResponse; + } + + preSets.typeExpected = preSets.typeExpected || 'object'; + if (typeof preSets.responseExpected == 'undefined') { + preSets.responseExpected = true; + } + + try { + data = this.convertValuesToString(data, preSets.cleanUnicode); + } catch (e) { + // Empty cleaned text found. + errorResponse.message = this.translate.instant('mm.core.unicodenotsupportedcleanerror'); + return errorResponse; + } + + data.wsfunction = method; + data.wstoken = preSets.wsToken; + siteUrl = preSets.siteUrl + '/webservice/rest/server.php?moodlewsrestformat=json'; + + // Serialize data. + data = this.utils.serialize(data); + + // Perform sync request using XMLHttpRequest. + xhr = new (window).XMLHttpRequest(); + xhr.open('post', siteUrl, false); + xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded;charset=utf-8'); + + xhr.send(data); + + // Get response. + data = ('response' in xhr) ? xhr.response : xhr.responseText; + + // Check status. + xhr.status = Math.max(xhr.status === 1223 ? 204 : xhr.status, 0); + if (xhr.status < 200 || xhr.status >= 300) { + // Request failed. + errorResponse.message = data; + return errorResponse; + } + + // Treat response. + try { + data = JSON.parse(data); + } catch(ex) {} + + // Some moodle web services return null. + // If the responseExpected value is set then so long as no data is returned, we create a blank object. + if ((!data || !data.data) && !preSets.responseExpected) { + data = {}; + } + + if (!data) { + errorResponse.message = this.translate.instant('mm.core.serverconnection'); + } else if (typeof data != preSets.typeExpected) { + this.logger.warn('Response of type "' + typeof data + '" received, expecting "' + preSets.typeExpected + '"'); + errorResponse.message = this.translate.instant('mm.core.errorinvalidresponse'); + } + + if (typeof data.exception != 'undefined' || typeof data.debuginfo != 'undefined') { + errorResponse.message = data.message; + } + + if (errorResponse.message !== '') { + return errorResponse; + } + + return data; + } + + /* + * Uploads a file. + * + * @param {string} filePath File path. + * @param {CoreWSFileUploadOptions} options File upload options. + * @param {CoreWSPreSets} preSets Must contain siteUrl and wsToken. + * @return {Promise} Promise resolved when uploaded. + */ + uploadFile(filePath: string, options: CoreWSFileUploadOptions, preSets: CoreWSPreSets) : Promise { + this.logger.debug(`Trying to upload file: ${filePath}`); + + if (!filePath || !options || !preSets) { + return Promise.reject(null); + } + + if (!this.appProvider.isOnline()) { + return Promise.reject(this.translate.instant('mm.core.networkerrormsg')); + } + + let uploadUrl = preSets.siteUrl + '/webservice/upload.php', + transfer = this.fileTransfer.create(); + + options.httpMethod = 'POST'; + options.params = { + token: preSets.wsToken, + filearea: options.fileArea || 'draft', + itemid: options.itemId || 0 + }; + options.chunkedMode = false; + options.headers = { + Connection: "close" + }; + + return transfer.upload(filePath, uploadUrl, options, true).then((success) => { + let data: any = success.response; + try { + data = JSON.parse(data); + } catch(err) { + this.logger.error('Error parsing response from upload:', err, data); + return Promise.reject(this.translate.instant('mm.core.errorinvalidresponse')); + } + + if (!data) { + return Promise.reject(this.translate.instant('mm.core.serverconnection')); + } else if (typeof data != 'object') { + this.logger.warn('Upload file: Response of type "' + typeof data + '" received, expecting "object"'); + return Promise.reject(this.translate.instant('mm.core.errorinvalidresponse')); + } + + if (typeof data.exception !== 'undefined') { + return Promise.reject(data.message); + } else if (data && typeof data.error !== 'undefined') { + return Promise.reject(data.error); + } else if (data[0] && typeof data[0].error !== 'undefined') { + return Promise.reject(data[0].error); + } + + // We uploaded only 1 file, so we only return the first file returned. + this.logger.debug('Successfully uploaded file', filePath); + return data[0]; + }, (error) => { + this.logger.error('Error while uploading file', filePath, error.exception); + return Promise.reject(this.translate.instant('mm.core.errorinvalidresponse')); + }); + } +}