// (C) Copyright 2015 Moodle Pty Ltd. // // 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 { CoreLang } from '@services/lang'; import { CoreTextUtils } from '@services/utils/text'; import { CoreConstants } from '@/core/constants'; import { makeSingleton } from '@singletons'; import { CoreUrl } from '@singletons/url'; /* * "Utils" service with helper functions for URLs. */ @Injectable({ providedIn: 'root' }) export class CoreUrlUtilsProvider { /** * Add or remove 'www' from a URL. The url needs to have http or https protocol. * * @param url URL to modify. * @return Modified URL. */ addOrRemoveWWW(url: string): string { if (url) { if (url.match(/http(s)?:\/\/www\./)) { // Already has www. Remove it. url = url.replace('www.', ''); } else { url = url.replace('https://', 'https://www.'); url = url.replace('http://', 'http://www.'); } } return url; } /** * Add params to a URL. * * @param url URL to add the params to. * @param params Object with the params to add. * @param anchor Anchor text if needed. * @param boolToNumber Whether to convert bools to 1 or 0. * @return URL with params. */ addParamsToUrl(url: string, params?: CoreUrlParams, anchor?: string, boolToNumber?: boolean): string { let separator = url.indexOf('?') != -1 ? '&' : '?'; for (const key in params) { let value = params[key]; if (boolToNumber && typeof value == 'boolean') { // Convert booleans to 1 or 0. value = value ? '1' : '0'; } // Ignore objects. if (typeof value != 'object') { url += separator + key + '=' + value; separator = '&'; } } if (anchor) { url += '#' + anchor; } return url; } /** * Given a URL and a text, return an HTML link. * * @param url URL. * @param text Text of the link. * @return Link. */ buildLink(url: string, text: string): string { return '' + text + ''; } /** * Check whether we can use tokenpluginfile.php endpoint for a certain URL. * * @param url URL to check. * @param siteUrl The URL of the site the URL belongs to. * @param accessKey User access key for tokenpluginfile. * @return Whether tokenpluginfile.php can be used. */ canUseTokenPluginFile(url: string, siteUrl: string, accessKey?: string): boolean { // Do not use tokenpluginfile if site doesn't use slash params, the URL doesn't work. // Also, only use it for "core" pluginfile endpoints. Some plugins can implement their own endpoint (like customcert). return !!accessKey && !url.match(/[&?]file=/) && ( url.indexOf(CoreTextUtils.instance.concatenatePaths(siteUrl, 'pluginfile.php')) === 0 || url.indexOf(CoreTextUtils.instance.concatenatePaths(siteUrl, 'webservice/pluginfile.php')) === 0); } /** * Extracts the parameters from a URL and stores them in an object. * * @param url URL to treat. * @return Object with the params. */ extractUrlParams(url: string): CoreUrlParams { const regex = /[?&]+([^=&]+)=?([^&]*)?/gi; const subParamsPlaceholder = '@@@SUBPARAMS@@@'; const params: CoreUrlParams = {}; const urlAndHash = url.split('#'); const questionMarkSplit = urlAndHash[0].split('?'); let subParams: string; if (questionMarkSplit.length > 2) { // There is more than one question mark in the URL. This can happen if any of the params is a URL with params. // We only want to treat the first level of params, so we'll remove this second list of params and restore it later. questionMarkSplit.splice(0, 2); subParams = '?' + questionMarkSplit.join('?'); urlAndHash[0] = urlAndHash[0].replace(subParams, subParamsPlaceholder); } urlAndHash[0].replace(regex, (match: string, key: string, value: string): string => { params[key] = typeof value != 'undefined' ? CoreTextUtils.instance.decodeURIComponent(value) : ''; if (subParams) { params[key] = params[key].replace(subParamsPlaceholder, subParams); } return match; }); if (urlAndHash.length > 1) { // Remove the URL from the array. urlAndHash.shift(); // Add the hash as a param with a special name. Use a join in case there is more than one #. params.urlHash = urlAndHash.join('#'); } return params; } /** * Generic function for adding the wstoken to Moodle urls and for pointing to the correct script. * For download remote files from Moodle we need to use the special /webservice/pluginfile passing * the ws token as a get parameter. * * @param url The url to be fixed. * @param token Token to use. * @param siteUrl The URL of the site the URL belongs to. * @param accessKey User access key for tokenpluginfile. * @return Fixed URL. */ fixPluginfileURL(url: string, token: string, siteUrl: string, accessKey?: string): string { if (!url) { return ''; } url = url.replace(/&/g, '&'); const canUseTokenPluginFile = accessKey && this.canUseTokenPluginFile(url, siteUrl, accessKey); // First check if we need to fix this url or is already fixed. if (!canUseTokenPluginFile && url.indexOf('token=') != -1) { return url; } // Check if is a valid URL (contains the pluginfile endpoint) and belongs to the site. if (!this.isPluginFileUrl(url) || url.indexOf(CoreTextUtils.instance.addEndingSlash(siteUrl)) !== 0) { return url; } if (canUseTokenPluginFile) { // Use tokenpluginfile.php. url = url.replace(/(\/webservice)?\/pluginfile\.php/, '/tokenpluginfile.php/' + accessKey); } else { // Use pluginfile.php. Some webservices returns directly the correct download url, others not. if (url.indexOf(CoreTextUtils.instance.concatenatePaths(siteUrl, 'pluginfile.php')) === 0) { url = url.replace('/pluginfile', '/webservice/pluginfile'); } url = this.addParamsToUrl(url, { token }); } return this.addParamsToUrl(url, { offline: '1' }); // Always send offline=1 (it's for external repositories). } /** * Formats a URL, trim, lowercase, etc... * * @param url The url to be formatted. * @return Fromatted url. */ formatURL(url: string): string { url = url.trim(); // Check if the URL starts by http or https. if (! /^http(s)?:\/\/.*/i.test(url)) { // Test first allways https. url = 'https://' + url; } // http always in lowercase. url = url.replace(/^http/i, 'http'); url = url.replace(/^https/i, 'https'); // Replace last slash. url = url.replace(/\/$/, ''); return url; } /** * Returns the URL to the documentation of the app, based on Moodle version and current language. * * @param release Moodle release. * @param page Docs page to go to. * @return Promise resolved with the Moodle docs URL. */ async getDocsUrl(release?: string, page: string = 'Mobile_app'): Promise { let docsUrl = 'https://docs.moodle.org/en/' + page; if (typeof release != 'undefined') { const version = release.substr(0, 3).replace('.', ''); // Check is a valid number. if (Number(version) >= 24) { // Append release number. docsUrl = docsUrl.replace('https://docs.moodle.org/', 'https://docs.moodle.org/' + version + '/'); } } try { const lang = await CoreLang.instance.getCurrentLanguage(); return docsUrl.replace('/en/', '/' + lang + '/'); } catch (error) { return docsUrl; } } /** * Returns the Youtube Embed Video URL or null if not found. * * @param url URL * @return Youtube Embed Video URL or null if not found. */ getYoutubeEmbedUrl(url: string): string | void { if (!url) { return; } let videoId = ''; const params: CoreUrlParams = {}; url = CoreTextUtils.instance.decodeHTML(url); // Get the video ID. let match = url.match(/^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|&v=)([^#&?]*).*/); if (match && match[2].length === 11) { videoId = match[2]; } // No videoId, do not continue. if (!videoId) { return; } // Now get the playlist (if any). match = url.match(/[?&]list=([^#&?]+)/); if (match && match[1]) { params.list = match[1]; } // Now get the start time (if any). match = url.match(/[?&]start=(\d+)/); if (match && match[1]) { params.start = parseInt(match[1], 10).toString(); } else { // No start param, but it could have a time param. match = url.match(/[?&]t=(\d+h)?(\d+m)?(\d+s)?/); if (match) { const start = (match[1] ? parseInt(match[1], 10) * 3600 : 0) + (match[2] ? parseInt(match[2], 10) * 60 : 0) + (match[3] ? parseInt(match[3], 10) : 0); params.start = start.toString(); } } return this.addParamsToUrl('https://www.youtube.com/embed/' + videoId, params); } /** * Given a URL, returns what's after the last '/' without params. * Example: * http://mysite.com/a/course.html?id=1 -> course.html * * @param url URL to treat. * @return Last file without params. */ getLastFileWithoutParams(url: string): string { let filename = url.substr(url.lastIndexOf('/') + 1); if (filename.indexOf('?') != -1) { filename = filename.substr(0, filename.indexOf('?')); } return filename; } /** * Get the protocol from a URL. * E.g. http://www.google.com returns 'http'. * * @param url URL to treat. * @return Protocol, undefined if no protocol found. */ getUrlProtocol(url: string): string | void { if (!url) { return; } const matches = url.match(/^([^/:.?]*):\/\//); if (matches && matches[1]) { return matches[1]; } } /** * Get the scheme from a URL. Please notice that, if a URL has protocol, it will return the protocol. * E.g. javascript:doSomething() returns 'javascript'. * * @param url URL to treat. * @return Scheme, undefined if no scheme found. */ getUrlScheme(url: string): string | void { if (!url) { return; } const matches = url.match(/^([a-z][a-z0-9+\-.]*):/); if (matches && matches[1]) { return matches[1]; } } /* * Gets a username from a URL like: user@mysite.com. * * @param url URL to treat. * @return Username. Undefined if no username found. */ getUsernameFromUrl(url: string): string | void { if (url.indexOf('@') > -1) { // Get URL without protocol. const withoutProtocol = url.replace(/^[^?@/]*:\/\//, ''); const matches = withoutProtocol.match(/[^@]*/); // Make sure that @ is at the start of the URL, not in a param at the end. if (matches && matches.length && !matches[0].match(/[/|?]/)) { return matches[0]; } } } /** * Returns if a URL has any protocol (not a relative URL). * * @param url The url to test against the pattern. * @return Whether the url is absolute. */ isAbsoluteURL(url: string): boolean { return /^[^:]{2,}:\/\//i.test(url) || /^(tel:|mailto:|geo:)/.test(url); } /** * Returns if a URL is downloadable: plugin file OR theme/image.php OR gravatar. * * @param url The URL to test. * @return Whether the URL is downloadable. */ isDownloadableUrl(url: string): boolean { return this.isPluginFileUrl(url) || this.isThemeImageUrl(url) || this.isGravatarUrl(url); } /** * Returns if a URL is a gravatar URL. * * @param url The URL to test. * @return Whether the URL is a gravatar URL. */ isGravatarUrl(url: string): boolean { return url?.indexOf('gravatar.com/avatar') !== -1; } /** * Check if a URL uses http or https protocol. * * @param url The url to test. * @return Whether the url uses http or https protocol. */ isHttpURL(url: string): boolean { return /^https?:\/\/.+/i.test(url); } /** * Check whether an URL belongs to a local file. * * @param url URL to check. * @return Whether the URL belongs to a local file. */ isLocalFileUrl(url: string): boolean { const urlParts = CoreUrl.parse(url); return this.isLocalFileUrlScheme(urlParts?.protocol || ''); } /** * Check whether a URL scheme belongs to a local file. * * @param scheme Scheme to check. * @return Whether the scheme belongs to a local file. */ isLocalFileUrlScheme(scheme: string): boolean { if (!scheme) { return false; } scheme = scheme.toLowerCase(); return scheme == 'cdvfile' || scheme == 'file' || scheme == 'filesystem' || scheme == CoreConstants.CONFIG.ioswebviewscheme; } /** * Returns if a URL is a pluginfile URL. * * @param url The URL to test. * @return Whether the URL is a pluginfile URL. */ isPluginFileUrl(url: string): boolean { return url?.indexOf('/pluginfile.php') !== -1; } /** * Returns if a URL is a theme image URL. * * @param url The URL to test. * @return Whether the URL is a theme image URL. */ isThemeImageUrl(url: string): boolean { return url?.indexOf('/theme/image.php') !== -1; } /** * Remove protocol and www from a URL. * * @param url URL to treat. * @return Treated URL. */ removeProtocolAndWWW(url: string): string { // Remove protocol. url = url.replace(/.*?:\/\//g, ''); // Remove www. url = url.replace(/^www./, ''); return url; } /** * Remove the parameters from a URL, returning the URL without them. * * @param url URL to treat. * @return URL without params. */ removeUrlParams(url: string): string { const matches = url.match(/^[^?]+/); return matches ? matches[0] : ''; } /** * Modifies a pluginfile URL to use the default pluginfile script instead of the webservice one. * * @param url The url to be fixed. * @param siteUrl The URL of the site the URL belongs to. * @return Modified URL. */ unfixPluginfileURL(url: string, siteUrl?: string): string { if (!url) { return ''; } url = url.replace(/&/g, '&'); // It site URL is supplied, check if the URL belongs to the site. if (siteUrl && url.indexOf(CoreTextUtils.instance.addEndingSlash(siteUrl)) !== 0) { return url; } // Not a pluginfile URL. Treat webservice/pluginfile case. url = url.replace(/\/webservice\/pluginfile\.php\//, '/pluginfile.php/'); // Make sure the URL doesn't contain the token. url.replace(/([?&])token=[^&]*&?/, '$1'); return url; } } export class CoreUrlUtils extends makeSingleton(CoreUrlUtilsProvider) {} export type CoreUrlParams = {[key: string]: string};