using MediaBrowser.Common.IO; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Localization; using MediaBrowser.Model.Logging; using MediaBrowser.Model.Serialization; using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Text; using System.Threading.Tasks; using WebMarkupMin.Core; using WebMarkupMin.Core.Minifiers; using WebMarkupMin.Core.Settings; namespace MediaBrowser.WebDashboard.Api { public class PackageCreator { private readonly IFileSystem _fileSystem; private readonly ILocalizationManager _localization; private readonly ILogger _logger; private readonly IServerConfigurationManager _config; private readonly IJsonSerializer _jsonSerializer; public PackageCreator(IFileSystem fileSystem, ILocalizationManager localization, ILogger logger, IServerConfigurationManager config, IJsonSerializer jsonSerializer) { _fileSystem = fileSystem; _localization = localization; _logger = logger; _config = config; _jsonSerializer = jsonSerializer; } public async Task GetResource(string path, string mode, string localizationCulture, string appVersion, bool enableMinification) { Stream resourceStream; if (path.Equals("scripts/all.js", StringComparison.OrdinalIgnoreCase)) { resourceStream = await GetAllJavascript(mode, localizationCulture, appVersion, enableMinification).ConfigureAwait(false); enableMinification = false; } else if (path.Equals("css/all.css", StringComparison.OrdinalIgnoreCase)) { resourceStream = await GetAllCss(enableMinification).ConfigureAwait(false); enableMinification = false; } else { resourceStream = GetRawResourceStream(path); } if (resourceStream != null) { // Don't apply any caching for html pages // jQuery ajax doesn't seem to handle if-modified-since correctly if (IsFormat(path, "html")) { if (IsCoreHtml(path)) { resourceStream = await ModifyHtml(resourceStream, mode, localizationCulture, enableMinification).ConfigureAwait(false); } } else if (IsFormat(path, "js")) { if (path.IndexOf("thirdparty", StringComparison.OrdinalIgnoreCase) == -1) { resourceStream = await ModifyJs(resourceStream, enableMinification).ConfigureAwait(false); } } else if (IsFormat(path, "css")) { if (path.IndexOf("thirdparty", StringComparison.OrdinalIgnoreCase) == -1) { resourceStream = await ModifyCss(resourceStream, enableMinification).ConfigureAwait(false); } } } return resourceStream; } /// /// Determines whether the specified path is HTML. /// /// The path. /// The format. /// true if the specified path is HTML; otherwise, false. private bool IsFormat(string path, string format) { return Path.GetExtension(path).EndsWith(format, StringComparison.OrdinalIgnoreCase); } /// /// Gets the dashboard UI path. /// /// The dashboard UI path. public string DashboardUIPath { get { if (!string.IsNullOrEmpty(_config.Configuration.DashboardSourcePath)) { return _config.Configuration.DashboardSourcePath; } return Path.Combine(_config.ApplicationPaths.ApplicationResourcesPath, "dashboard-ui"); } } /// /// Gets the dashboard resource path. /// /// The virtual path. /// System.String. private string GetDashboardResourcePath(string virtualPath) { var rootPath = DashboardUIPath; var fullPath = Path.Combine(rootPath, virtualPath.Replace('/', Path.DirectorySeparatorChar)); // Don't allow file system access outside of the source folder if (!_fileSystem.ContainsSubPath(rootPath, fullPath)) { throw new UnauthorizedAccessException(); } return fullPath; } public async Task ModifyCss(Stream sourceStream, bool enableMinification) { using (sourceStream) { string content; using (var memoryStream = new MemoryStream()) { await sourceStream.CopyToAsync(memoryStream).ConfigureAwait(false); content = Encoding.UTF8.GetString(memoryStream.ToArray()); if (enableMinification) { try { var result = new KristensenCssMinifier().Minify(content, false, Encoding.UTF8); if (result.Errors.Count > 0) { _logger.Error("Error minifying css: " + result.Errors[0].Message); } else { content = result.MinifiedContent; } } catch (Exception ex) { _logger.ErrorException("Error minifying css", ex); } } } var bytes = Encoding.UTF8.GetBytes(content); return new MemoryStream(bytes); } } public async Task ModifyJs(Stream sourceStream, bool enableMinification) { using (sourceStream) { string content; using (var memoryStream = new MemoryStream()) { await sourceStream.CopyToAsync(memoryStream).ConfigureAwait(false); content = Encoding.UTF8.GetString(memoryStream.ToArray()); if (enableMinification) { try { var result = new CrockfordJsMinifier().Minify(content, false, Encoding.UTF8); if (result.Errors.Count > 0) { _logger.Error("Error minifying javascript: " + result.Errors[0].Message); } else { content = result.MinifiedContent; } } catch (Exception ex) { _logger.ErrorException("Error minifying javascript", ex); } } } var bytes = Encoding.UTF8.GetBytes(content); return new MemoryStream(bytes); } } private bool IsCoreHtml(string path) { if (path.IndexOf("vulcanize", StringComparison.OrdinalIgnoreCase) != -1) { return false; } path = GetDashboardResourcePath(path); var parent = Path.GetDirectoryName(path); var basePath = DashboardUIPath; return string.Equals(basePath, parent, StringComparison.OrdinalIgnoreCase) || string.Equals(Path.Combine(basePath, "voice"), parent, StringComparison.OrdinalIgnoreCase); } /// /// Modifies the HTML by adding common meta tags, css and js. /// /// The source stream. /// The mode. /// The localization culture. /// if set to true [enable minification]. /// Task{Stream}. public async Task ModifyHtml(Stream sourceStream, string mode, string localizationCulture, bool enableMinification) { using (sourceStream) { string html; using (var memoryStream = new MemoryStream()) { await sourceStream.CopyToAsync(memoryStream).ConfigureAwait(false); html = Encoding.UTF8.GetString(memoryStream.ToArray()); if (string.Equals(mode, "cordova", StringComparison.OrdinalIgnoreCase)) { html = ModifyForCordova(html); } if (!string.IsNullOrWhiteSpace(localizationCulture)) { var lang = localizationCulture.Split('-').FirstOrDefault(); html = _localization.LocalizeDocument(html, localizationCulture, GetLocalizationToken); html = html.Replace("", ""); } if (enableMinification) { try { var minifier = new HtmlMinifier(new HtmlMinificationSettings { AttributeQuotesRemovalMode = HtmlAttributeQuotesRemovalMode.KeepQuotes, RemoveOptionalEndTags = false, RemoveTagsWithoutContent = false }); var result = minifier.Minify(html, false); if (result.Errors.Count > 0) { _logger.Error("Error minifying html: " + result.Errors[0].Message); } else { html = result.MinifiedContent; } } catch (Exception ex) { _logger.ErrorException("Error minifying html", ex); } } } var version = GetType().Assembly.GetName().Version; var imports = new string[] { "vulcanize-out.html" }; var importsHtml = string.Join("", imports.Select(i => "").ToArray()); // It would be better to make polymer completely dynamic and loaded on demand, but seeing issues with that // In chrome it is causing the body to be hidden while loading, which leads to width-check methods to return 0 for everything //imports = ""; html = html.Replace("", "" + GetMetaTags(mode) + GetCommonCss(mode, version) + GetCommonJavascript(mode, version) + importsHtml); var bytes = Encoding.UTF8.GetBytes(html); return new MemoryStream(bytes); } } private string ModifyForCordova(string html) { // Strip everything between CORDOVA_EXCLUDE_START and CORDOVA_EXCLUDE_END html = ReplaceBetween(html, "", "", string.Empty); // Replace CORDOVA_REPLACE_SUPPORTER_SUBMIT_START html = ReplaceBetween(html, "", "", "${ButtonPurchase}"); return html; } private string ReplaceBetween(string html, string startToken, string endToken, string newHtml) { var start = html.IndexOf(startToken, StringComparison.OrdinalIgnoreCase); if (start == -1) { return html; } var end = html.IndexOf(endToken, start, StringComparison.OrdinalIgnoreCase); if (end == -1) { return html; } string result = html.Substring(start, end - start); html = html.Replace(result, newHtml); return ReplaceBetween(html, startToken, endToken, newHtml); } private string GetLocalizationToken(string phrase) { return "${" + phrase + "}"; } /// /// Gets the meta tags. /// /// System.String. private static string GetMetaTags(string mode) { var sb = new StringBuilder(); if (string.Equals(mode, "cordova", StringComparison.OrdinalIgnoreCase)) { //sb.Append(""); } sb.Append(""); sb.Append(""); sb.Append(""); sb.Append(""); sb.Append(""); sb.Append(""); sb.Append(""); //sb.Append(""); sb.Append(""); // http://developer.apple.com/library/ios/#DOCUMENTATION/AppleApplications/Reference/SafariWebContent/ConfiguringWebApplications/ConfiguringWebApplications.html sb.Append(""); sb.Append(""); sb.Append(""); sb.Append(""); sb.Append(""); sb.Append(""); sb.Append(""); return sb.ToString(); } /// /// Gets the common CSS. /// /// The mode. /// The version. /// System.String. private string GetCommonCss(string mode, Version version) { var versionString = !string.Equals(mode, "cordova", StringComparison.OrdinalIgnoreCase) ? "?v=" + version : string.Empty; var files = new[] { "thirdparty/fontawesome/css/font-awesome.min.css" + versionString, "thirdparty/materialicons/style.css" + versionString, "css/all.css" + versionString }; var tags = files.Select(s => string.Format("", s)).ToArray(); return string.Join(string.Empty, tags); } /// /// Gets the common javascript. /// /// The mode. /// The version. /// System.String. private string GetCommonJavascript(string mode, Version version) { var builder = new StringBuilder(); var versionString = !string.Equals(mode, "cordova", StringComparison.OrdinalIgnoreCase) ? "?v=" + version : string.Empty; var files = new List { "scripts/all.js" + versionString }; if (string.Equals(mode, "cordova", StringComparison.OrdinalIgnoreCase)) { files.Insert(0, "cordova.js"); } var tags = files.Select(s => string.Format("", s)).ToArray(); builder.Append(string.Join(string.Empty, tags)); return builder.ToString(); } /// /// Gets a stream containing all concatenated javascript /// /// Task{Stream}. private async Task GetAllJavascript(string mode, string culture, string version, bool enableMinification) { var memoryStream = new MemoryStream(); var newLineBytes = Encoding.UTF8.GetBytes(Environment.NewLine); await AppendResource(memoryStream, "bower_components/webcomponentsjs/webcomponents-lite.min.js", newLineBytes).ConfigureAwait(false); // jQuery + jQuery mobile await AppendResource(memoryStream, "thirdparty/jquery-2.1.1.min.js", newLineBytes).ConfigureAwait(false); await AppendResource(memoryStream, "thirdparty/jquerymobile-1.4.5/jquery.mobile-1.4.5.min.js", newLineBytes).ConfigureAwait(false); await AppendResource(memoryStream, "thirdparty/browser.js", newLineBytes).ConfigureAwait(false); await AppendResource(memoryStream, "thirdparty/require.js", newLineBytes).ConfigureAwait(false); await AppendResource(memoryStream, "thirdparty/jquery.unveil-custom.js", newLineBytes).ConfigureAwait(false); var excludePhrases = new List(); if (string.Equals(mode, "cordova", StringComparison.OrdinalIgnoreCase)) { excludePhrases.Add("paypal"); } await AppendLocalization(memoryStream, culture, excludePhrases).ConfigureAwait(false); await memoryStream.WriteAsync(newLineBytes, 0, newLineBytes.Length).ConfigureAwait(false); if (!string.IsNullOrWhiteSpace(mode)) { var appModeBytes = Encoding.UTF8.GetBytes(string.Format("window.appMode='{0}';", mode)); await memoryStream.WriteAsync(appModeBytes, 0, appModeBytes.Length).ConfigureAwait(false); } // Write the version string for the dashboard comparison function var versionString = string.Format("window.dashboardVersion='{0}';", version); var versionBytes = Encoding.UTF8.GetBytes(versionString); await memoryStream.WriteAsync(versionBytes, 0, versionBytes.Length).ConfigureAwait(false); await memoryStream.WriteAsync(newLineBytes, 0, newLineBytes.Length).ConfigureAwait(false); var builder = new StringBuilder(); var apiClientFiles = new[] { "apiclient/logger.js", "apiclient/md5.js", "apiclient/sha1.js", "apiclient/store.js", "apiclient/device.js", "apiclient/credentials.js", "apiclient/ajax.js", "apiclient/events.js", "apiclient/deferred.js", "apiclient/apiclient.js" }.ToList(); apiClientFiles.Add("apiclient/connectionmanager.js"); foreach (var file in apiClientFiles) { using (var fs = _fileSystem.GetFileStream(GetDashboardResourcePath(file), FileMode.Open, FileAccess.Read, FileShare.ReadWrite, true)) { using (var streamReader = new StreamReader(fs)) { var text = await streamReader.ReadToEndAsync().ConfigureAwait(false); builder.Append(text); builder.Append(Environment.NewLine); } } } foreach (var file in GetScriptFiles()) { var path = GetDashboardResourcePath("scripts/" + file); using (var fs = _fileSystem.GetFileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite, true)) { using (var streamReader = new StreamReader(fs)) { var text = await streamReader.ReadToEndAsync().ConfigureAwait(false); builder.Append(text); builder.Append(Environment.NewLine); } } } var js = builder.ToString(); if (enableMinification) { try { var result = new CrockfordJsMinifier().Minify(js, false, Encoding.UTF8); if (result.Errors.Count > 0) { _logger.Error("Error minifying javascript: " + result.Errors[0].Message); } else { js = result.MinifiedContent; } } catch (Exception ex) { _logger.ErrorException("Error minifying javascript", ex); } } var bytes = Encoding.UTF8.GetBytes(js); await memoryStream.WriteAsync(bytes, 0, bytes.Length).ConfigureAwait(false); memoryStream.Position = 0; return memoryStream; } private IEnumerable GetScriptFiles() { return new[] { "extensions.js", "site.js", "librarybrowser.js", "librarylist.js", "librarymenu.js", "mediacontroller.js", "backdrops.js", "sync.js", "playlistmanager.js", "appsettings.js", "mediaplayer.js", "mediaplayer-video.js", "nowplayingbar.js", "alphapicker.js", "directorybrowser.js", "moviecollections.js", "notifications.js", "remotecontrol.js", "search.js", "thememediaplayer.js" }; } private async Task AppendLocalization(Stream stream, string culture, List excludePhrases) { var dictionary = _localization.GetJavaScriptLocalizationDictionary(culture); if (excludePhrases.Count > 0) { var removes = new List(); foreach (var pair in dictionary) { if (excludePhrases.Any(i => pair.Key.IndexOf(i, StringComparison.OrdinalIgnoreCase) != -1 || pair.Value.IndexOf(i, StringComparison.OrdinalIgnoreCase) != -1)) { removes.Add(pair.Key); } } foreach (var remove in removes) { dictionary.Remove(remove); } } var js = "window.localizationGlossary=" + _jsonSerializer.SerializeToString(dictionary); var bytes = Encoding.UTF8.GetBytes(js); await stream.WriteAsync(bytes, 0, bytes.Length).ConfigureAwait(false); } /// /// Appends the resource. /// /// The output stream. /// The path. /// The new line bytes. /// Task. private async Task AppendResource(Stream outputStream, string path, byte[] newLineBytes) { path = GetDashboardResourcePath(path); using (var fs = _fileSystem.GetFileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite, true)) { using (var streamReader = new StreamReader(fs)) { var text = await streamReader.ReadToEndAsync().ConfigureAwait(false); var bytes = Encoding.UTF8.GetBytes(text); await outputStream.WriteAsync(bytes, 0, bytes.Length).ConfigureAwait(false); } } await outputStream.WriteAsync(newLineBytes, 0, newLineBytes.Length).ConfigureAwait(false); } /// /// Gets all CSS. /// /// Task{Stream}. private async Task GetAllCss(bool enableMinification) { var memoryStream = new MemoryStream(); var newLineBytes = Encoding.UTF8.GetBytes(Environment.NewLine); await AppendResource(memoryStream, "thirdparty/jquerymobile-1.4.5/jquery.mobile.custom.theme.min.css", newLineBytes).ConfigureAwait(false); await AppendResource(memoryStream, "thirdparty/jquerymobile-1.4.5/jquery.mobile.custom.structure.min.css", newLineBytes).ConfigureAwait(false); var files = new[] { "site.css", "chromecast.css", "mediaplayer.css", "mediaplayer-video.css", "librarymenu.css", "librarybrowser.css", "detailtable.css", "card.css", "tileitem.css", "metadataeditor.css", "notifications.css", "search.css", "pluginupdates.css", "remotecontrol.css", "userimage.css", "livetv.css", "nowplaying.css", "materialize.css" }; var builder = new StringBuilder(); foreach (var file in files) { var path = GetDashboardResourcePath("css/" + file); using (var fs = _fileSystem.GetFileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite, true)) { using (var streamReader = new StreamReader(fs)) { var text = await streamReader.ReadToEndAsync().ConfigureAwait(false); builder.Append(text); builder.Append(Environment.NewLine); } } } var css = builder.ToString(); if (enableMinification) { try { var result = new KristensenCssMinifier().Minify(builder.ToString(), false, Encoding.UTF8); if (result.Errors.Count > 0) { _logger.Error("Error minifying css: " + result.Errors[0].Message); } else { css = result.MinifiedContent; } } catch (Exception ex) { _logger.ErrorException("Error minifying css", ex); } } var bytes = Encoding.UTF8.GetBytes(css); memoryStream.Write(bytes, 0, bytes.Length); memoryStream.Position = 0; return memoryStream; } /// /// Gets the raw resource stream. /// /// The path. /// Task{Stream}. private Stream GetRawResourceStream(string path) { return _fileSystem.GetFileStream(GetDashboardResourcePath(path), FileMode.Open, FileAccess.Read, FileShare.ReadWrite, true); } } }