// Copyright (C) 2024, The Duplicati Team // https://duplicati.com, hello@duplicati.com // // Permission is hereby granted, free of charge, to any person obtaining a // copy of this software and associated documentation files (the "Software"), // to deal in the Software without restriction, including without limitation // the rights to use, copy, modify, merge, publish, distribute, sublicense, // and/or sell copies of the Software, and to permit persons to whom the // Software is furnished to do so, subject to the following conditions: // // The above copyright notice and this permission notice shall be included in // all copies or substantial portions of the Software. // // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS // OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. using System; using System.Collections.Specialized; using System.Linq; using System.Text; namespace Duplicati.Library.Utility { /// /// Represents a relaxed parsing of a URL. /// The goal is to cover as many types of url's as possible, /// without being ambiguous. /// The major limitations is that an embedded username may not contain a :, /// and the password may not contain a @. /// public struct Uri { /// /// A very lax version of a URL parser /// private static readonly System.Text.RegularExpressions.Regex URL_PARSER = new System.Text.RegularExpressions.Regex(@"(?[^:]+)://(((?[^\:\?/]+)(\:(?[^@\:\?/]*))?\@))?((?(?:[^\[/\?\:][^/\?\:]*)|(?:\[[^\]]+\]))(\:(?\d+))?)?((?[^\?]*))?(\?(?.+))?"); /// /// The URL scheme, e.g. http /// public readonly string Scheme; /// /// The server name, e.g. www.example.com /// public readonly string Host; /// /// The server path, e.g. index.html /// public readonly string Path; /// /// The server port, e.g. 80, is -1 if using the default port /// public readonly int Port; /// /// The querystring, e.g. ?id=1 /// public readonly string Query; /// /// The username, if any /// public readonly string Username; /// /// The password, if any /// public readonly string Password; /// /// The original URI. /// public readonly string OriginalUri; /// /// Cache for the query parameters. /// private NameValueCollection m_queryParams; /// /// Gets the parameters in the query string /// /// The query parameters. public NameValueCollection QueryParameters { get { if (m_queryParams == null) { if (Query == null) m_queryParams = new NameValueCollection(); else m_queryParams = ParseQueryString(Query); } return m_queryParams; } } /// /// Gets the host and path. /// /// The host and path. public string HostAndPath { get { if (string.IsNullOrEmpty(Path)) return Host; else if (string.IsNullOrEmpty(Host)) return Path; else return Host + (Path == null ? "" : "/" + Path); } } /// /// Gets the path and query. /// /// The path and query. public string PathAndQuery { get { return (Path ?? "") + (Query == null ? "" : "?" + Query); } } /// /// Initializes a new instance of the struct. /// /// The URL to parse public Uri(string url) { if (string.IsNullOrEmpty(url)) throw new ArgumentNullException(nameof(url)); m_queryParams = null; this.OriginalUri = url; var m = URL_PARSER.Match(url); if (!m.Success || m.Length != url.Length) { var path = url; if (path.StartsWith("file://", StringComparison.OrdinalIgnoreCase)) path = path.Substring("file://".Length); if (path.IndexOfAny(System.IO.Path.GetInvalidPathChars()) < 0) try { var fp = System.IO.Path.GetFullPath(path); this.Scheme = "file"; this.Host = null; this.Path = fp; this.Port = -1; this.Query = null; this.Username = null; this.Password = null; return; } catch { } throw new ArgumentException(Strings.Uri.UriParseError(url), nameof(url)); } this.Scheme = m.Groups["scheme"].Value; var h = m.Groups["hostname"].Success ? m.Groups["hostname"].Value : ""; var p = m.Groups["path"].Success ? m.Groups["path"].Value : ""; if (m.Groups["hostname"].Success && p.StartsWith("/", StringComparison.Ordinal)) p = p.Substring(1); // file://c:\test support if (h.Length == 1 && p.StartsWith(":", StringComparison.Ordinal)) { h = h + p; p = ""; } this.Host = h; this.Path = p; this.Query = m.Groups["query"].Success ? m.Groups["query"].Value : null; this.Username = m.Groups["username"].Success ? UrlDecode(m.Groups["username"].Value) : null; this.Password = m.Groups["password"].Success ? UrlDecode(m.Groups["password"].Value) : null; if (m.Groups["port"].Success) this.Port = int.Parse(m.Groups["port"].Value); else this.Port = -1; } /// /// Constructs a free-form URI from components /// /// The url scheme, e.g. http /// The hostname, e.g. www.example.com /// The path, e.g. index.html /// The query string, e.g. id=1 /// The username /// The password /// The port public Uri(string scheme, string host, string path = null, string query = null, string username = null, string password = null, int port = -1) { m_queryParams = null; Scheme = scheme; Host = host; Path = path; Query = query; Username = username; Password = password; Port = port; OriginalUri = AsString(scheme, host, path, query, username, password, port); } /// /// Returns a that represents the current . /// /// A that represents the current . public override string ToString() { return AsString(Scheme, Host, Path, Query, Username, Password, Port); } /// /// Throws an exception if the host name is missing. /// public void RequireHost() { if (string.IsNullOrEmpty(Host)) throw new ArgumentException(Strings.Uri.NoHostname(OriginalUri)); } /// /// Constructs an url-like string from components. /// /// An url-like string /// The url scheme, e.g. http /// The hostname, e.g. www.example.com /// The path, e.g. index.html /// The query string, e.g. id=1 /// The username /// The password /// The port private static string AsString(string scheme, string host, string path, string query, string username, string password, int port) { var s = scheme + "://"; if (!string.IsNullOrEmpty(username) || !string.IsNullOrEmpty(password)) { s += UrlEncode(username ?? ""); s += ":"; s += UrlEncode(password ?? ""); s += "@"; } if (!string.IsNullOrEmpty(host)) { s += host; if (port != -1) s += ":" + port.ToString(); } if (!string.IsNullOrEmpty(path)) { if (!string.IsNullOrEmpty(host) && !path.StartsWith("/", StringComparison.Ordinal)) s += "/"; s += path; } if (!string.IsNullOrEmpty(query)) s += "?" + query; return s; } /// /// Creates a new instance with another scheme /// /// A new instance /// The new scheme to use public Uri SetScheme(string scheme) { return new Uri(scheme, Host, Path, Query, Username, Password, Port); } /// /// Creates a new instance with another host /// /// A new instance /// The new hostname to use public Uri SetHost(string host) { return new Uri(Scheme, host, Path, Query, Username, Password, Port); } /// /// Creates a new instance with another path /// /// A new instance /// The new path to use public Uri SetPath(string path) { return new Uri(Scheme, Host, path, Query, Username, Password, Port); } /// /// Creates a new instance with another query /// /// A new instance /// The new query to use public Uri SetQuery(string query) { return new Uri(Scheme, Host, Path, query, Username, Password, Port); } /// /// Creates a new instance with other credentials /// /// A new instance /// The new username to use /// The new password to use public Uri SetCredentials(string username, string password) { return new Uri(Scheme, Host, Path, Query, username, password, Port); } /// /// Creates a new instance with another port /// /// A new instance /// The new port to use public Uri SetPort(int port) { return new Uri(Scheme, Host, Path, Query, Username, Password, port); } /// /// The regular expression that matches %20 type values in a querystring /// private static readonly System.Text.RegularExpressions.Regex RE_ESCAPECHAR = new System.Text.RegularExpressions.Regex(@"[^0-9a-zA-Z\-_]", System.Text.RegularExpressions.RegexOptions.Compiled); /// /// Encodes a URL, like System.Web.HttpUtility.UrlEncode /// /// The encoded URL /// The URL fragment to encode /// The encoding to use public static string UrlPathEncode(string value, System.Text.Encoding encoding = null) { return UrlEncode(value, encoding, "%20"); } /// /// Encodes a URL, like System.Web.HttpUtility.UrlEncode /// /// The encoded URL /// The URL fragment to encode /// The encoding to use public static string UrlEncode(string value, System.Text.Encoding encoding = null, string spacevalue = "+") { if (value == null) throw new ArgumentNullException(nameof(value)); encoding = encoding ?? System.Text.Encoding.UTF8; var encoder = encoding.GetEncoder(); var inbuf = new char[1]; var outbuf = new byte[4]; return RE_ESCAPECHAR.Replace(value, (m) => { if (m.Value == " ") return spacevalue; inbuf[0] = m.Value[0]; try { var len = encoder.GetBytes(inbuf, 0, 1, outbuf, 0, true); return "%" + BitConverter.ToString(outbuf, 0, len).Replace("-", "%"); } catch { } //Fallback return m.Value; }); } /// /// The regular expression that matches %20 type values in a querystring /// private static readonly System.Text.RegularExpressions.Regex RE_NUMBER = new System.Text.RegularExpressions.Regex(@"(\%(?([0-9]|[a-f]|[A-F]){2}))|(\+)|(\%u(?([0-9]|[a-f]|[A-F]){4}))", System.Text.RegularExpressions.RegexOptions.Compiled); /// /// Decodes a URL, like System.Web.HttpUtility.UrlDecode /// /// The decoded URL /// The URL fragment to decode /// The encoding to use public static string UrlDecode(string value, System.Text.Encoding encoding = null) { if (value == null) throw new ArgumentNullException(nameof(value)); encoding = encoding ?? System.Text.Encoding.UTF8; var decoder = encoding.GetDecoder(); var inbuf = new byte[8]; var outbuf = new char[8]; return RE_NUMBER.Replace(value, (m) => { if (m.Value == "+") return " "; try { var hex = m.Groups["number"].Value; var bytelen = hex.Length / 2; Utility.HexStringAsByteArray(hex, inbuf); var c = decoder.GetChars(inbuf, 0, bytelen, outbuf, 0); return new string(outbuf, 0, c); } catch { } //Fallback return m.Value; }); } /// /// The regular expression that matches a=b type values in a querystring /// private static readonly System.Text.RegularExpressions.Regex RE_URLPARAM = new System.Text.RegularExpressions.Regex(@"(?[^\=\&]+)(\=(?[^\&]*))?", System.Text.RegularExpressions.RegexOptions.Compiled); /// /// Parses the query string. /// This is a duplicate of the System.Web.HttpUtility.ParseQueryString that does not work well on Mono /// /// The parsed query string /// The query to parse public static NameValueCollection ParseQueryString(string query) { return Library.Utility.Uri.ParseQueryString(query, true); } /// /// Parses the query string. /// This is a duplicate of the System.Web.HttpUtility.ParseQueryString that does not work well on Mono /// /// The parsed query string /// The query to parse /// Whether to the parameter values should be decoded or not. public static NameValueCollection ParseQueryString(string query, bool decodeValues) { if (query == null) throw new ArgumentNullException(nameof(query)); if (query.StartsWith("?", StringComparison.Ordinal)) query = query.Substring(1); if (string.IsNullOrEmpty(query)) return new NameValueCollection(StringComparer.OrdinalIgnoreCase); var result = new NameValueCollection(StringComparer.OrdinalIgnoreCase); foreach (System.Text.RegularExpressions.Match m in RE_URLPARAM.Matches(query)) { string value = m.Groups["value"].Success ? m.Groups["value"].Value : ""; if (decodeValues) { value = UrlDecode(value); } result.Add(UrlDecode(m.Groups["key"].Value), value); } return result; } /// /// Build the querystring to be used in a URL /// /// The generated querystring /// A collection of name value pairs to be translated into a query string /// The delimiter to separate key value pairs in the query string public static string BuildUriQuery(NameValueCollection query, string delimiter) { if (query == null) throw new ArgumentNullException(nameof(query)); StringBuilder builder = new StringBuilder(); foreach (var key in query.Cast().Where(key => !string.IsNullOrEmpty(query[key]))) { builder.Append(builder.Length == 0 ? string.Empty : delimiter) .Append(key) .Append("=") .Append(query[key]); } return builder.ToString(); } /// /// Build the querystring to be used in a URL /// /// The generated querystring /// A collection of name value pairs to be translated into a query string that is /// ampersand delimited. public static string BuildUriQuery(NameValueCollection query) { return BuildUriQuery(query, "&"); } /// /// Builds a URL together using a base URL, a path and a query. /// /// The built together URL. /// Base URL, containing schema, host, port. /// Base path. /// A collection of name value pairs to be translated into a query string. public static string UriBuilder(string url, string path, NameValueCollection query) { var builder = new UriBuilder(url) { Path = (new UrlPath(ExtractPath(url)).Append(path)).ToString(), Query = query != null ? BuildUriQuery(query) : null }; return builder.Uri.AbsoluteUri; } /// /// Grab path part of a URI. /// At the moment, simple implementation does not remove fragments. /// /// The path. /// URL. public static string ExtractPath(string url) { return (new Uri(url)).Path; } /// /// Builds a URL together using a base URL and path. /// /// The built together URL. /// Base URL, containing schema, host, port. /// Base path. public static string UriBuilder(string url, string path) { return UriBuilder(url, path, null); } } }