From fe6a24a0f29cb8f476474450d18c37b4438087c3 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Tue, 14 Nov 2017 08:58:20 +0100 Subject: [PATCH] MOBILE-2261 sites: Implement sites provider --- src/app/app.module.ts | 16 + src/classes/site.ts | 28 +- src/core/constants.ts | 7 + src/providers/app.ts | 7 +- src/providers/sites-factory.ts | 6 +- src/providers/sites.ts | 1020 ++++++++++++++++++++++++++++++++ src/providers/utils/utils.ts | 12 +- 7 files changed, 1074 insertions(+), 22 deletions(-) create mode 100644 src/providers/sites.ts diff --git a/src/app/app.module.ts b/src/app/app.module.ts index b628174fc..18663bafb 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -1,3 +1,17 @@ +// (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 { BrowserModule } from '@angular/platform-browser'; import { ErrorHandler, NgModule } from '@angular/core'; import { IonicApp, IonicErrorHandler, IonicModule, Platform } from 'ionic-angular'; @@ -28,6 +42,7 @@ import { CoreFileProvider } from '../providers/file'; import { CoreWSProvider } from '../providers/ws'; import { CoreEventsProvider } from '../providers/events'; import { CoreSitesFactoryProvider } from '../providers/sites-factory'; +import { CoreSitesProvider } from '../providers/sites'; // For translate loader. AoT requires an exported function for factories. export function createTranslateLoader(http: HttpClient) { @@ -77,6 +92,7 @@ export function createTranslateLoader(http: HttpClient) { CoreWSProvider, CoreEventsProvider, CoreSitesFactoryProvider, + CoreSitesProvider, ] }) export class AppModule { diff --git a/src/classes/site.ts b/src/classes/site.ts index 3097746e9..7bc9e4ece 100644 --- a/src/classes/site.ts +++ b/src/classes/site.ts @@ -73,9 +73,9 @@ export class CoreSite { protected wsProvider; // Variables for the database. - protected WS_CACHE_STORE = 'wscache'; + protected WS_CACHE_TABLE = 'wscache'; protected tableSchema = { - name: this.WS_CACHE_STORE, + name: this.WS_CACHE_TABLE, columns: [ { name: 'id', @@ -142,13 +142,13 @@ export class CoreSite { * @param {Injector} injector Angular injector to prevent having to pass all the required services. * @param {string} id Site ID. * @param {string} siteUrl Site URL. - * @param {string} token Site's WS token. - * @param {any} info Site info. + * @param {string} [token] Site's WS token. + * @param {any} [info] Site info. * @param {string} [privateToken] Private token. * @param {any} [config] Site public config. * @param {boolean} [loggedOut] Whether user is logged out. */ - constructor(injector: Injector, public id: string, public siteUrl: string, public token: string, public infos: any, + constructor(injector: Injector, public id: string, public siteUrl: string, public token?: string, public infos?: any, public privateToken?: string, public config?: any, public loggedOut?: boolean) { // Inject the required services. let logger = injector.get(CoreLoggerProvider); @@ -627,10 +627,10 @@ export class CoreSite { } if (preSets.getCacheUsingCacheKey || (emergency && preSets.getEmergencyCacheUsingCacheKey)) { - promise = this.db.getRecords(this.WS_CACHE_STORE, {key: preSets.cacheKey}).then((entries) => { + promise = this.db.getRecords(this.WS_CACHE_TABLE, {key: preSets.cacheKey}).then((entries) => { if (!entries.length) { // Cache key not found, get by params sent. - return this.db.getRecord(this.WS_CACHE_STORE, {id: id}); + return this.db.getRecord(this.WS_CACHE_TABLE, {id: id}); } else if (entries.length > 1) { // More than one entry found. Search the one with same ID as this call. for (let i = 0, len = entries.length; i < len; i++) { @@ -643,7 +643,7 @@ export class CoreSite { return entries[0]; }); } else { - promise = this.db.getRecord(this.WS_CACHE_STORE, {id: id}); + promise = this.db.getRecord(this.WS_CACHE_TABLE, {id: id}); } return promise.then((entry) => { @@ -704,7 +704,7 @@ export class CoreSite { if (preSets.cacheKey) { entry.key = preSets.cacheKey; } - return this.db.insertOrUpdateRecord(this.WS_CACHE_STORE, entry, {id: id}); + return this.db.insertOrUpdateRecord(this.WS_CACHE_TABLE, entry, {id: id}); }); } } @@ -725,9 +725,9 @@ export class CoreSite { return Promise.reject(null); } else { if (allCacheKey) { - return this.db.deleteRecords(this.WS_CACHE_STORE, {key: preSets.cacheKey}); + return this.db.deleteRecords(this.WS_CACHE_TABLE, {key: preSets.cacheKey}); } else { - return this.db.deleteRecords(this.WS_CACHE_STORE, {id: id}); + return this.db.deleteRecords(this.WS_CACHE_TABLE, {id: id}); } } } @@ -761,7 +761,7 @@ export class CoreSite { } this.logger.debug('Invalidate all the cache for site: ' + this.id); - return this.db.updateRecords(this.WS_CACHE_STORE, {expirationTime: 0}); + return this.db.updateRecords(this.WS_CACHE_TABLE, {expirationTime: 0}); } /** @@ -779,7 +779,7 @@ export class CoreSite { } this.logger.debug('Invalidate cache for key: ' + key); - return this.db.updateRecords(this.WS_CACHE_STORE, {expirationTime: 0}, {key: key}); + return this.db.updateRecords(this.WS_CACHE_TABLE, {expirationTime: 0}, {key: key}); } /** @@ -821,7 +821,7 @@ export class CoreSite { } this.logger.debug('Invalidate cache for key starting with: ' + key); - let sql = 'UPDATE ' + this.WS_CACHE_STORE + ' SET expirationTime=0 WHERE key LIKE ?%'; + let sql = 'UPDATE ' + this.WS_CACHE_TABLE + ' SET expirationTime=0 WHERE key LIKE ?%'; return this.db.execute(sql, [key]); } diff --git a/src/core/constants.ts b/src/core/constants.ts index 7290b7d1b..0519e2948 100644 --- a/src/core/constants.ts +++ b/src/core/constants.ts @@ -24,6 +24,13 @@ export class CoreConstants { public static downloadThreshold = 10485760; // 10MB. public static dontShowError = 'CoreDontShowError'; public static settingsRichTextEditor = 'CoreSettingsRichTextEditor'; + + // WS constants. public static wsTimeout = 30000; public static wsPrefix = 'local_mobile_'; + + // Login constants. + public static loginSSOCode = 2; // SSO in browser window is required. + public static loginSSOInAppCode = 3; // SSO in embedded browser is required. + public static loginLaunchData = 'mmLoginLaunchData'; } diff --git a/src/providers/app.ts b/src/providers/app.ts index ec267f4f6..4ec2c3b39 100644 --- a/src/providers/app.ts +++ b/src/providers/app.ts @@ -19,6 +19,7 @@ import { Network } from '@ionic-native/network'; import { CoreDbProvider } from './db'; import { CoreLoggerProvider } from './logger'; +import { SQLiteDB } from '../classes/sqlitedb'; /** * Factory to provide some global functionalities, like access to the global app database. @@ -33,7 +34,7 @@ import { CoreLoggerProvider } from './logger'; @Injectable() export class CoreAppProvider { DBNAME = 'MoodleMobile'; - db; + db: SQLiteDB; logger; ssoAuthenticationPromise : Promise; isKeyboardShown: boolean = false; @@ -81,9 +82,9 @@ export class CoreAppProvider { /** * Get the application global database. * - * @return {any} App's DB. + * @return {SQLiteDB} App's DB. */ - getDB() : any { + getDB() : SQLiteDB { if (typeof this.db == 'undefined') { this.db = this.dbProvider.getDB(this.DBNAME); } diff --git a/src/providers/sites-factory.ts b/src/providers/sites-factory.ts index 99d400332..4b5e45da3 100644 --- a/src/providers/sites-factory.ts +++ b/src/providers/sites-factory.ts @@ -28,8 +28,8 @@ export class CoreSitesFactoryProvider { * * @param {string} id Site ID. * @param {string} siteUrl Site URL. - * @param {string} token Site's WS token. - * @param {any} info Site info. + * @param {string} [token] Site's WS token. + * @param {any} [info] Site info. * @param {string} [privateToken] Private token. * @param {any} [config] Site public config. * @param {boolean} [loggedOut] Whether user is logged out. @@ -37,7 +37,7 @@ export class CoreSitesFactoryProvider { * @description * This returns a site object. */ - makeSite = function(id: string, siteUrl: string, token: string, info: any, privateToken?: string, + makeSite(id: string, siteUrl: string, token?: string, info?: any, privateToken?: string, config?: any, loggedOut?: boolean) : CoreSite { return new CoreSite(this.injector, id, siteUrl, token, info, privateToken, config, loggedOut); } diff --git a/src/providers/sites.ts b/src/providers/sites.ts new file mode 100644 index 000000000..cc09aa3e0 --- /dev/null +++ b/src/providers/sites.ts @@ -0,0 +1,1020 @@ +// (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 { CoreAppProvider } from './app'; +import { CoreEventsProvider } from './events'; +import { CoreLoggerProvider } from './logger'; +import { CoreSitesFactoryProvider } from './sites-factory'; +import { CoreUrlUtilsProvider } from './utils/url'; +import { CoreUtilsProvider } from './utils/utils'; +import { CoreConstants } from '../core/constants'; +import { CoreConfigConstants } from '../configconstants'; +import { CoreSite } from '../classes/site'; +import { SQLiteDB } from '../classes/sqlitedb'; +import { Md5 } from 'ts-md5/dist/md5'; + +export interface CoreSiteCheckResponse { + code: number; // Code to identify the authentication method to use. + siteUrl: string; // Site url to use (might have changed during the process). + service: string; // Service used. + warning?: string; // Code of the warning message to show to the user. + config?: any; // Site public config (if available). +}; + +export interface CoreSiteUserTokenResponse { + token: string; // User token. + siteUrl: string; // Site URL to use. + privateToken?: string; // User private token. +}; + +export interface CoreSiteBasicInfo { + id: string; + siteUrl: string; + fullName: string; + siteName: string; + avatar: string; +}; + +/* + * Service to manage and interact with sites. +*/ +@Injectable() +export class CoreSitesProvider { + // Variables for the database. + protected SITES_TABLE = 'sites'; + protected CURRENT_SITE_TABLE = 'current_site'; + protected tablesSchema = [ + { + name: this.SITES_TABLE, + columns: [ + { + name: 'id', + type: 'TEXT', + primaryKey: true + }, + { + name: 'siteUrl', + type: 'TEXT', + notNull: true + }, + { + name: 'token', + type: 'TEXT' + }, + { + name: 'info', + type: 'TEXT' + }, + { + name: 'privateToken', + type: 'TEXT' + }, + { + name: 'config', + type: 'TEXT' + }, + { + name: 'loggedOut', + type: 'INTEGER' + } + ] + }, + { + name: this.CURRENT_SITE_TABLE, + columns: [ + { + name: 'id', + type: 'INTEGER', + primaryKey: true + }, + { + name: 'siteId', + type: 'TEXT', + notNull: true, + unique: true + } + ] + } + ]; + + // Constants to validate a site version. + protected VALID_VERSION = 1; + protected LEGACY_APP_VERSION = 0; + protected INVALID_VERSION = -1; + + protected logger; + protected services = {}; + protected sessionRestored = false; + protected currentSite: CoreSite; + protected sites: {[s: string]: CoreSite} = {}; + protected appDB: SQLiteDB; + + constructor(logger: CoreLoggerProvider, private http: HttpClient, private sitesFactory: CoreSitesFactoryProvider, + private appProvider: CoreAppProvider, private utils: CoreUtilsProvider, private translate: TranslateService, + private eventsProvider: CoreEventsProvider, private urlUtils: CoreUrlUtilsProvider) { + this.logger = logger.getInstance('CoreSitesProvider'); + + this.appDB = appProvider.getDB(); + this.appDB.createTablesFromSchema(this.tablesSchema); + } + + /** + * Get the demo data for a certain "name" if it is a demo site. + * + * @param {string} name Name of the site to check. + * @return {any} Site data if it's a demo site, undefined otherwise. + */ + getDemoSiteData(name: string) { + const demoSites = CoreConfigConstants.demo_sites; + if (typeof demoSites != 'undefined' && typeof demoSites[name] != 'undefined') { + return demoSites[name]; + } + } + + /** + * Check if a site is valid and if it has specifics settings for authentication (like force to log in using the browser). + * It will test both protocols if the first one fails: http and https. + * + * @param {string} siteUrl URL of the site to check. + * @param {string} [protocol=https://] Protocol to use first. + * @return {Promise} A promise resolved when the site is checked. + */ + checkSite(siteUrl: string, protocol = 'https://') : Promise { + // formatURL adds the protocol if is missing. + siteUrl = this.urlUtils.formatURL(siteUrl); + + if (!this.urlUtils.isHttpURL(siteUrl)) { + return Promise.reject(this.translate.instant('mm.login.invalidsite')); + } else if (!this.appProvider.isOnline()) { + return Promise.reject(this.translate.instant('mm.core.networkerrormsg')); + } else { + return this.checkSiteWithProtocol(siteUrl, protocol).catch((error) => { + // Do not continue checking if a critical error happened. + if (error.critical) { + return Promise.reject(error.error); + } + + // Retry with the other protocol. + protocol = protocol == 'https://' ? 'http://' : 'https://'; + return this.checkSiteWithProtocol(siteUrl, protocol).catch((secondError) => { + // Site doesn't exist. + if (secondError.error) { + return Promise.reject(secondError.error); + } else if (error.error) { + return Promise.reject(error.error); + } + return Promise.reject(this.translate.instant('mm.login.checksiteversion')); + }); + }); + } + } + + /** + * Helper function to check if a site is valid and if it has specifics settings for authentication. + * + * @param {string} siteUrl URL of the site to check. + * @param {string} protocol Protocol to use. + * @return {Promise} A promise resolved when the site is checked. + */ + checkSiteWithProtocol(siteUrl: string, protocol: string) : Promise { + let publicConfig; + + // Now, replace the siteUrl with the protocol. + siteUrl = siteUrl.replace(/^http(s)?\:\/\//i, protocol); + + return this.siteExists(siteUrl).catch((error) => { + // Do not continue checking if WS are not enabled. + if (error.errorcode && error.errorcode == 'enablewsdescription') { + return rejectWithCriticalError(error.error, error.errorcode); + } + + // Site doesn't exist. Try to add or remove 'www'. + const treatedUrl = this.urlUtils.addOrRemoveWWW(siteUrl); + return this.siteExists(treatedUrl).then(() => { + // Success, use this new URL as site url. + siteUrl = treatedUrl; + }).catch((secondError) => { + // Do not continue checking if WS are not enabled. + if (secondError.errorcode && secondError.errorcode == 'enablewsdescription') { + return rejectWithCriticalError(secondError.error, secondError.errorcode); + } + + error = secondError || error; + return Promise.reject({error: typeof error == 'object' ? error.error : error}); + }); + }).then(() => { + // Create a temporary site to check if local_mobile is installed. + const temporarySite = this.sitesFactory.makeSite(undefined, siteUrl); + return temporarySite.checkLocalMobilePlugin().then((data) => { + data.service = data.service || CoreConfigConstants.wsservice; + this.services[siteUrl] = data.service; // No need to store it in DB. + + if (data.coreSupported || + (data.code != CoreConstants.loginSSOCode && data.code != CoreConstants.loginSSOInAppCode)) { + // SSO using local_mobile not needed, try to get the site public config. + return temporarySite.getPublicConfig().then((config) : any => { + publicConfig = config; + + // Check that the user can authenticate. + if (!config.enablewebservices) { + return rejectWithCriticalError(this.translate.instant('mm.login.webservicesnotenabled')); + } else if (!config.enablemobilewebservice) { + return rejectWithCriticalError(this.translate.instant('mm.login.mobileservicesnotenabled')); + } else if (config.maintenanceenabled) { + let message = this.translate.instant('mm.core.sitemaintenance'); + if (config.maintenancemessage) { + message += config.maintenancemessage; + } + return rejectWithCriticalError(message); + } + + // Everything ok. + if (data.code === 0) { + data.code = config.typeoflogin; + } + return data; + }, (error) : any => { + // Error, check if not supported. + if (error.available === 1) { + // Service supported but an error happened. Return error. + return Promise.reject({error: error.error}); + } + + return data; + }); + } + + return data; + }).then((data) => { + siteUrl = temporarySite.getURL(); + return {siteUrl: siteUrl, code: data.code, warning: data.warning, service: data.service, config: publicConfig}; + }); + }); + + // Return a rejected promise with a "critical" error. + function rejectWithCriticalError(message: string, errorCode?: string) { + return Promise.reject({ + error: message, + errorcode: errorCode, + critical: true + }); + } + } + + /** + * Check if a site exists. + * + * @param {string} siteUrl URL of the site to check. + * @return {Promise} A promise to be resolved if the site exists. + */ + siteExists(siteUrl: string) : Promise { + let data: any = {}; + + if (!this.appProvider.isMobile()) { + // Send fake parameters for CORS. This is only needed in browser. + data.username = 'a'; + data.password = 'b'; + data.service = 'c'; + } + + const observable = this.http.post(siteUrl + '/login/token.php', data).timeout(CoreConstants.wsTimeout); + return this.utils.observableToPromise(observable).then((data: any) => { + if (data.errorcode && (data.errorcode == 'enablewsdescription' || data.errorcode == 'requirecorrectaccess')) { + return Promise.reject({errorcode: data.errorcode, error: data.error}); + } else if (data.error && data.error == 'Web services must be enabled in Advanced features.') { + return Promise.reject({errorcode: 'enablewsdescription', error: data.error}); + } + // Other errors are not being checked because invalid login will be always raised and we cannot differ them. + }); + } + + /** + * Gets a user token from the server. + * + * @param {string} siteUrl The site url. + * @param {string} username User name. + * @param {string} password Password. + * @param {string} [service] Service to use. If not defined, it will be searched in memory. + * @param {boolean} [retry] Whether we are retrying with a prefixed URL. + * @return {Promise} A promise resolved when the token is retrieved. + */ + getUserToken(siteUrl: string, username: string, password: string, service?: string, retry?: boolean) + : Promise { + if (!this.appProvider.isOnline()) { + return Promise.reject(this.translate.instant('mm.core.networkerrormsg')); + } + + if (!service) { + service = this.determineService(siteUrl); + } + + const params = { + username: username, + password: password, + service: service + }, + observable = this.http.post(siteUrl + '/login/token.php', params).timeout(CoreConstants.wsTimeout); + + return this.utils.observableToPromise(observable).then((data: any) : any => { + if (typeof data == 'undefined') { + return Promise.reject(this.translate.instant('mm.core.cannotconnect')); + } else { + if (typeof data.token != 'undefined') { + return {token: data.token, siteUrl: siteUrl, privateToken: data.privatetoken}; + } else { + if (typeof data.error != 'undefined') { + // We only allow one retry (to avoid loops). + if (!retry && data.errorcode == "requirecorrectaccess") { + siteUrl = this.urlUtils.addOrRemoveWWW(siteUrl); + return this.getUserToken(siteUrl, username, password, service, true); + } else if (typeof data.errorcode != 'undefined') { + return Promise.reject({error: data.error, errorcode: data.errorcode}); + } else { + return Promise.reject(data.error); + } + } else { + return Promise.reject(this.translate.instant('mm.login.invalidaccount')); + } + } + } + }, () => { + return Promise.reject(this.translate.instant('mm.core.cannotconnect')); + }); + } + + /** + * Add a new site to the site list and authenticate the user in this site. + * + * @param {string} siteUrl The site url. + * @param {string} token User's token. + * @param {string} [privateToken=''] User's private token. + * @return {Promise} A promise resolved when the site is added and the user is authenticated. + */ + newSite(siteUrl: string, token: string, privateToken = '') : Promise { + // Create a "candidate" site to fetch the site info. + const candidateSite = this.sitesFactory.makeSite(undefined, siteUrl, token, undefined, privateToken); + + return candidateSite.fetchSiteInfo().then((info) => { + let result = this.isValidMoodleVersion(info); + if (result == this.VALID_VERSION) { + // Set site ID and info. + const siteId = this.createSiteID(info.siteurl, info.username); + candidateSite.setId(siteId); + candidateSite.setInfo(info); + + // Try to get the site config. + return this.getSiteConfig(candidateSite).then((config) => { + candidateSite.setConfig(config); + // Add site to sites list. + this.addSite(siteId, siteUrl, token, info, privateToken, config); + // Turn candidate site into current site. + this.currentSite = candidateSite; + // Store session. + this.login(siteId); + this.eventsProvider.trigger(CoreEventsProvider.SITE_ADDED, siteId); + + return siteId; + }); + } else if (result == this.LEGACY_APP_VERSION) { + return Promise.reject(this.translate.instant('mm.login.legacymoodleversion')); + } else { + return Promise.reject(this.translate.instant('mm.login.invalidmoodleversion')); + } + }); + } + + /** + * Create a site ID based on site URL and username. + * + * @param {string} siteUrl The site url. + * @param {string} username Username. + * @return {string} Site ID. + */ + createSiteID(siteUrl: string, username: string) : string { + return Md5.hashAsciiStr(siteUrl + username); + } + + /** + * Function for determine which service we should use (default or extended plugin). + * + * @param {string} siteUrl The site URL. + * @return {string} The service shortname. + */ + determineService(siteUrl: string) : string { + // We need to try siteUrl in both https or http (due to loginhttps setting). + + // First http:// + siteUrl = siteUrl.replace('https://', 'http://'); + if (this.services[siteUrl]) { + return this.services[siteUrl]; + } + + // Now https:// + siteUrl = siteUrl.replace('http://', 'https://'); + if (this.services[siteUrl]) { + return this.services[siteUrl]; + } + + // Return default service. + return CoreConfigConstants.wsservice; + } + + /** + * Check for the minimum required version. + * + * @param {any} info Site info. + * @return {number} Either VALID_VERSION, LEGACY_APP_VERSION or INVALID_VERSION. + */ + protected isValidMoodleVersion(info: any) : number { + if (!info) { + return this.INVALID_VERSION; + } + + const version24 = 2012120300, // Moodle 2.4 version. + release24 = '2.4', + version31 = 2016052300, + release31 = '3.1'; + + // Try to validate by version. + if (info.version) { + const version = parseInt(info.version, 10); + if (!isNaN(version)) { + if (version >= version31) { + return this.VALID_VERSION; + } else if (version >= version24) { + return this.LEGACY_APP_VERSION; + } else { + return this.INVALID_VERSION; + } + } + } + + // We couldn't validate by version number. Let's try to validate by release number. + if (info.release) { + const matches = info.release.match(/^([\d|\.]*)/); + if (matches && matches.length > 1) { + if (matches[1] >= release31) { + return this.VALID_VERSION; + } else if (matches[1] >= release24) { + return this.LEGACY_APP_VERSION; + } else { + return this.INVALID_VERSION; + } + } + } + + // Couldn't validate it. + return this.INVALID_VERSION; + } + + /** + * Check if site info is valid. If it's not, return error message. + * + * @param {any} info Site info. + * @return {any} True if valid, object with error message to show and its params if not valid. + */ + protected validateSiteInfo(info: any) : any { + if (!info.firstname || !info.lastname) { + const moodleLink = `${info.siteurl}`; + return {error: 'mm.core.requireduserdatamissing', params: {'$a': moodleLink}}; + } + return true; + } + + /** + * Saves a site in local DB. + * + * @param {string} id Site ID. + * @param {string} siteUrl Site URL. + * @param {string} token User's token in the site. + * @param {any} info Site's info. + * @param {string} [privateToken=''] User's private token. + * @param {any} [config] Site config (from tool_mobile_get_config). + * @return {Promise} Promise resolved when done. + */ + addSite(id: string, siteUrl: string, token: string, info: any, privateToken = '', config?: any) : Promise { + const entry = { + id: id, + siteUrl: siteUrl, + token: token, + info: info ? JSON.stringify(info) : info, + privateToken: privateToken, + config: config ? JSON.stringify(config) : config, + loggedOut: 0 + }; + return this.appDB.insertOrUpdateRecord(this.SITES_TABLE, entry, {id: id}); + } + + /** + * Login a user to a site from the list of sites. + * + * @param {string} siteId ID of the site to load. + * @return {Promise} Promise to be resolved when the site is loaded. + */ + loadSite(siteId: string) : Promise { + this.logger.debug(`Load site ${siteId}`); + + return this.getSite(siteId).then((site) => { + this.currentSite = site; + this.login(siteId); + + if (site.isLoggedOut()) { + // Logged out, nothing else to do. + return; + } + + // Check if local_mobile was installed to Moodle. + return site.checkIfLocalMobileInstalledAndNotUsed().then(() => { + // Local mobile was added. Throw invalid session to force reconnect and create a new token. + this.eventsProvider.trigger(CoreEventsProvider.SESSION_EXPIRED, {siteId: siteId}); + }, () => { + // Update site info. We don't block the UI. + this.updateSiteInfo(siteId); + }); + }); + } + + /** + * Get current site. + * + * @return {CoreSite} Current site. + */ + getCurrentSite() : CoreSite { + return this.currentSite; + } + + + /** + * Check if the user is logged in a site. + * + * @return {boolean} Whether the user is logged in a site. + */ + isLoggedIn() : boolean { + return typeof this.currentSite != 'undefined' && typeof this.currentSite.token != 'undefined' && + this.currentSite.token != ''; + } + + /** + * Delete a site from the sites list. + * + * @param {string} siteId ID of the site to delete. + * @return {Promise} Promise to be resolved when the site is deleted. + */ + deleteSite(siteId: string) : Promise { + this.logger.debug(`Delete site ${siteId}`); + + if (typeof this.currentSite != 'undefined' && this.currentSite.id == siteId) { + this.logout(); + } + + return this.getSite(siteId).then((site: CoreSite) => { + return site.deleteDB().then(() => { + // Site DB deleted, now delete the app from the list of sites. + delete this.sites[siteId]; + return this.appDB.deleteRecords(this.SITES_TABLE, {id: siteId}).then(() => { + // Site deleted from sites list, now delete the folder. + return site.deleteFolder(); + }, () => { + // DB remove shouldn't fail, but we'll go ahead even if it does. + return site.deleteFolder(); + }).then(() => { + this.eventsProvider.trigger(CoreEventsProvider.SITE_DELETED, site); + }); + }); + }); + } + + /** + * Check if there are no sites stored. + * + * @return {Promise} Promise resolved if there are no sites, and rejected if there is at least one. + */ + hasNoSites() : Promise { + return this.appDB.countRecords(this.SITES_TABLE).then((count) => { + if (count > 0) { + return Promise.reject(null); + } + }); + } + + /** + * Check if there are sites stored. + * + * @return {Promise} Promise resolved if there is at least one site, and rejected if there aren't. + */ + hasSites() : Promise { + return this.appDB.countRecords(this.SITES_TABLE).then((count) => { + if (count == 0) { + return Promise.reject(null); + } + }); + } + + /** + * Returns a site object. + * + * @param {string} [siteId] The site ID. If not defined, current site (if available). + * @return {Promise} Promise resolved with the site. + */ + getSite(siteId?: string) : Promise { + if (!siteId) { + return this.currentSite ? Promise.resolve(this.currentSite) : Promise.reject(null); + } else if (this.currentSite && this.currentSite.getId() == siteId) { + return Promise.resolve(this.currentSite); + } else if (typeof this.sites[siteId] != 'undefined') { + return Promise.resolve(this.sites[siteId]); + } else { + // retrieve and create the site. + return this.appDB.getRecord(this.SITES_TABLE, {id: siteId}).then((data) => { + return this.makeSiteFromSiteListEntry(data); + }); + } + } + + /** + * Create a site from an entry of the sites list DB. The new site is added to the list of "cached" sites: this.sites. + * + * @param {any} entry Site list entry. + * @return {CoreSite} Created site. + */ + makeSiteFromSiteListEntry(entry) : CoreSite { + let site, + info = entry.info, + config = entry.config; + + // Try to parse info and config. + try { + info = info ? JSON.parse(info) : info; + } catch(ex) {} + + try { + config = config ? JSON.parse(config) : config; + } catch(ex) {} + + site = this.sitesFactory.makeSite(entry.id, entry.siteUrl, entry.token, + info, entry.privateToken, config, entry.loggedOut == 1); + this.sites[entry.id] = site; + return site; + } + + /** + * Returns if the site is the current one. + * + * @param {string|CoreSite} [site] Site object or siteId to be compared. If not defined, use current site. + * @return {boolean} Whether site or siteId is the current one. + */ + isCurrentSite(site: string|CoreSite) : boolean { + if (!site || !this.currentSite) { + return !!this.currentSite; + } + + const siteId = typeof site == 'object' ? site.getId() : site; + return this.currentSite.getId() === siteId; + } + + /** + * Returns the database object of a site. + * + * @param {string} [siteId] The site ID. If not defined, current site (if available). + * @return {Promise} Promise resolved with the database. + */ + getSiteDb(siteId: string) : Promise { + return this.getSite(siteId).then((site) => { + return site.getDb(); + }); + } + + /** + * Returns the site home ID of a site. + * + * @param {number} [siteId] The site ID. If not defined, current site (if available). + * @return {Promise} Promise resolved with site home ID. + */ + getSiteHomeId(siteId: string) : Promise { + return this.getSite(siteId).then((site) => { + return site.getSiteHomeId(); + }); + } + + /** + * Get the list of sites stored. + * + * @param {String[]} [ids] IDs of the sites to get. If not defined, return all sites. + * @return {Promise} Promise resolved when the sites are retrieved. + */ + getSites(ids: string[]) : Promise { + return this.appDB.getAllRecords(this.SITES_TABLE).then((sites) => { + let formattedSites = []; + sites.forEach((site) => { + if (!ids || ids.indexOf(site.id) > -1) { + const basicInfo: CoreSiteBasicInfo = { + id: site.id, + siteUrl: site.siteUrl, + fullName: site.info.fullname, + siteName: site.info.sitename, + avatar: site.info.userpictureurl + }; + formattedSites.push(basicInfo); + } + }); + return formattedSites; + }); + } + + /** + * Get the list of IDs of sites stored. + * + * @return {Promise} Promise resolved when the sites IDs are retrieved. + */ + getSitesIds(): Promise { + return this.appDB.getAllRecords(this.SITES_TABLE).then((sites) => { + return sites.map((site) => { + return site.id; + }); + }); + } + + /** + * Login the user in a site. + * + * @param {string} siteid ID of the site the user is accessing. + * @return {Promise} Promise resolved when current site is stored. + */ + login(siteId: string) : Promise { + const entry = { + id: 1, + siteId: siteId + }; + return this.appDB.insertOrUpdateRecord(this.CURRENT_SITE_TABLE, entry, {id: 1}).then(() => { + this.eventsProvider.trigger(CoreEventsProvider.LOGIN, {siteId: siteId}); + }); + } + + /** + * Logout the user. + * + * @return {Promise} Promise resolved when the user is logged out. + */ + logout() : Promise { + if (!this.currentSite) { + // Already logged out. + return Promise.resolve(); + } + + const siteId = this.currentSite.getId(), + siteConfig = this.currentSite.getStoredConfig(), + promises = []; + + this.currentSite = undefined; + + if (siteConfig && siteConfig.tool_mobile_forcelogout == '1') { + promises.push(this.setSiteLoggedOut(siteId, true)); + } + + promises.push(this.appDB.deleteRecords(this.CURRENT_SITE_TABLE, {id: 1})); + + return Promise.all(promises).finally(() => { + this.eventsProvider.trigger(CoreEventsProvider.LOGOUT, {siteId: siteId}); + }); + } + + /** + * Restores the session to the previous one so the user doesn't has to login everytime the app is started. + * + * @return {Promise} Promise resolved if a session is restored. + */ + restoreSession() : Promise { + if (this.sessionRestored) { + return Promise.reject(null); + } + + this.sessionRestored = true; + + return this.appDB.getRecord(this.CURRENT_SITE_TABLE, {id: 1}).then((currentSite) => { + const siteId = currentSite.siteId; + this.logger.debug(`Restore session in site ${siteId}`); + return this.loadSite(siteId); + }); + } + + /** + * Mark or unmark a site as logged out so the user needs to authenticate again. + * + * @param {string} siteId ID of the site. + * @param {boolean} loggedOut True to set the site as logged out, false otherwise. + * @return {Promise} Promise resolved when done. + */ + setSiteLoggedOut(siteId: string, loggedOut: boolean) : Promise { + return this.getSite(siteId).then((site) => { + const newValues = { + token: '', // Erase the token for security. + loggedOut: loggedOut ? 1 : 0 + }; + + site.setLoggedOut(loggedOut); + + return this.appDB.updateRecords(this.SITES_TABLE, newValues, {id: siteId}); + }); + } + + /** + * Updates a site's token. + * + * @param {string} siteUrl Site's URL. + * @param {string} username Username. + * @param {string} token User's new token. + * @param {string} [privateToken=''] User's private token. + * @return {Promise} A promise resolved when the site is updated. + */ + updateSiteToken(siteUrl: string, username: string, token: string, privateToken = '') : Promise { + const siteId = this.createSiteID(siteUrl, username); + return this.updateSiteTokenBySiteId(siteId, token, privateToken); + } + + /** + * Updates a site's token using siteId. + * + * @param {string} siteId Site Id. + * @param {string} token User's new token. + * @param {string} [privateToken=''] User's private token. + * @return {Promise} A promise resolved when the site is updated. + */ + updateSiteTokenBySiteId(siteId: string, token: string, privateToken = '') : Promise { + return this.getSite(siteId).then((site) => { + const newValues = { + token: token, + privateToken: privateToken, + loggedOut: 0 + }; + + site.token = token; + site.privateToken = privateToken; + site.setLoggedOut(false); // Token updated means the user authenticated again, not logged out anymore. + + return this.appDB.updateRecords(this.SITES_TABLE, newValues, {id: siteId}); + }); + } + + /** + * Updates a site's info. + * + * @param {string} siteid Site's ID. + * @return {Promise} A promise resolved when the site is updated. + */ + updateSiteInfo(siteId: string) : Promise { + return this.getSite(siteId).then((site) => { + return site.fetchSiteInfo().then((info) => { + site.setInfo(info); + + // Try to get the site config. + return this.getSiteConfig(site).catch(() => { + // Error getting config, keep the current one. + }).then((config) => { + let newValues: any = { + info: JSON.stringify(info), + loggedOut: site.isLoggedOut() ? 1 : 0 + }; + + if (typeof config != 'undefined') { + site.setConfig(config); + newValues.config = JSON.stringify(config); + } + + return this.appDB.updateRecords(this.SITES_TABLE, newValues, {id: siteId}).finally(() => { + this.eventsProvider.trigger(CoreEventsProvider.SITE_UPDATED, {siteId: siteId}); + }); + }); + }); + }); + } + + /** + * Updates a site's info. + * + * @param {string} siteUrl Site's URL. + * @param {string} username Username. + * @return {Promise} A promise to be resolved when the site is updated. + */ + updateSiteInfoByUrl(siteUrl: string, username: string) : Promise { + const siteId = this.createSiteID(siteUrl, username); + return this.updateSiteInfo(siteId); + } + + /** + * Get the site IDs a URL belongs to. + * Someone can have more than one account in the same site, that's why this function returns an array of IDs. + * + * @param {string} url URL to check. + * @param {boolean} [prioritize] True if it should prioritize current site. If the URL belongs to current site then it won't + * check any other site, it will only return current site. + * @param {string} [username] If set, it will return only the sites where the current user has this username. + * @return {Promise} Promise resolved with the site IDs (array). + */ + getSiteIdsFromUrl(url: string, prioritize?: boolean, username?: string) : Promise { + // If prioritize is true, check current site first. + if (prioritize && this.currentSite && this.currentSite.containsUrl(url)) { + if (!username || this.currentSite.getInfo().username == username) { + return Promise.resolve([this.currentSite.getId()]); + } + } + + // Check if URL has http(s) protocol. + if (!url.match(/^https?:\/\//i)) { + // URL doesn't have http(s) protocol. Check if it has any protocol. + if (this.urlUtils.isAbsoluteURL(url)) { + // It has some protocol. Return empty array. + return Promise.resolve([]); + } else { + // No protocol, probably a relative URL. Return current site. + if (this.currentSite) { + return Promise.resolve([this.currentSite.getId()]); + } else { + return Promise.resolve([]); + } + } + } + + return this.appDB.getAllRecords(this.SITES_TABLE).then((siteEntries) => { + let ids = []; + siteEntries.forEach((site) => { + if (!this.sites[site.id]) { + this.makeSiteFromSiteListEntry(site); + } + + if (this.sites[site.id].containsUrl(url)) { + if (!username || this.sites[site.id].getInfo().username == username) { + ids.push(site.id); + } + } + }); + return ids; + }).catch(() => { + // Shouldn't happen. + return []; + }); + } + + /** + * Get the site ID stored in DB as current site. + * + * @return {Promise} Promise resolved with the site ID. + */ + getStoredCurrentSiteId() : Promise { + return this.appDB.getRecord(this.CURRENT_SITE_TABLE, {id: 1}).then((currentSite) => { + return currentSite.siteId; + }); + } + + /** + * Get the public config of a certain site. + * + * @param {string} siteUrl URL of the site. + * @return {Promise} Promise resolved with the public config. + */ + getSitePublicConfig(siteUrl: string) : Promise { + const temporarySite = this.sitesFactory.makeSite(undefined, siteUrl); + return temporarySite.getPublicConfig(); + } + + /** + * Get site config. + * + * @param {any} site The site to get the config. + * @return {Promise} Promise resolved with config if available. + */ + protected getSiteConfig(site: CoreSite) : Promise { + if (!site.wsAvailable('tool_mobile_get_config')) { + // WS not available, cannot get config. + return Promise.resolve(); + } + + return site.getConfig(null, true); + } + + /** + * Check if a certain feature is disabled in a site. + * + * @param {string} name Name of the feature to check. + * @param {string} [siteId] The site ID. If not defined, current site (if available). + * @return {Promise} Promise resolved with true if disabled. + */ + isFeatureDisabled(name: string, siteId?: string) : Promise { + return this.getSite(siteId).then((site) => { + return site.isFeatureDisabled(name); + }); + } +} diff --git a/src/providers/utils/utils.ts b/src/providers/utils/utils.ts index 52ec9dfda..7193c076b 100644 --- a/src/providers/utils/utils.ts +++ b/src/providers/utils/utils.ts @@ -414,7 +414,7 @@ export class CoreUtilsProvider { for (let i in siteIds) { let siteId = siteIds[i]; if (checkAll || !promises.length) { - promises.push(Promise.resolve(isEnabledFn.apply(isEnabledFn, [siteId].concat(...args))).then((enabled) => { + promises.push(Promise.resolve(isEnabledFn.apply(isEnabledFn, [siteId].concat(args))).then((enabled) => { if (enabled) { enabledSites.push(siteId); } @@ -953,7 +953,15 @@ export class CoreUtilsProvider { */ observableToPromise(obs: Observable) : Promise { return new Promise((resolve, reject) => { - obs.subscribe(resolve, reject); + let subscription = obs.subscribe((data) => { + // Data received, unsubscribe. + subscription.unsubscribe(); + resolve(data); + }, (error) => { + // Data received, unsubscribe. + subscription.unsubscribe(); + reject(error); + }); }); }