This commit is contained in:
VictoriousRaptor 2026-03-10 14:06:39 +08:00 committed by GitHub
commit ff1dbe2a5c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 147 additions and 68 deletions

View file

@ -1,33 +1,84 @@
using NUnit.Framework;
using NUnit.Framework.Legacy;
using Flow.Launcher.Plugin.Url;
using System.Reflection;
namespace Flow.Launcher.Test.Plugins
{
[TestFixture]
public class UrlPluginTest
{
[Test]
public void URLMatchTest()
private static Main plugin;
[OneTimeSetUp]
public void OneTimeSetup()
{
var plugin = new Main();
ClassicAssert.IsTrue(plugin.IsURL("http://www.google.com"));
ClassicAssert.IsTrue(plugin.IsURL("https://www.google.com"));
ClassicAssert.IsTrue(plugin.IsURL("http://google.com"));
ClassicAssert.IsTrue(plugin.IsURL("www.google.com"));
ClassicAssert.IsTrue(plugin.IsURL("google.com"));
ClassicAssert.IsTrue(plugin.IsURL("http://localhost"));
ClassicAssert.IsTrue(plugin.IsURL("https://localhost"));
ClassicAssert.IsTrue(plugin.IsURL("http://localhost:80"));
ClassicAssert.IsTrue(plugin.IsURL("https://localhost:80"));
ClassicAssert.IsTrue(plugin.IsURL("http://110.10.10.10"));
ClassicAssert.IsTrue(plugin.IsURL("110.10.10.10"));
ClassicAssert.IsTrue(plugin.IsURL("ftp://110.10.10.10"));
var settingsProperty = typeof(Main).GetProperty("Settings", BindingFlags.NonPublic | BindingFlags.Static);
settingsProperty?.SetValue(null, new Settings());
plugin = new Main();
}
ClassicAssert.IsFalse(plugin.IsURL("wwww"));
ClassicAssert.IsFalse(plugin.IsURL("wwww.c"));
ClassicAssert.IsFalse(plugin.IsURL("wwww.c"));
[TestCase("http://www.google.com")]
[TestCase("https://www.google.com")]
[TestCase("http://google.com")]
[TestCase("ftp://google.com")]
[TestCase("www.google.com")]
[TestCase("google.com")]
[TestCase("http://localhost")]
[TestCase("https://localhost")]
[TestCase("http://localhost:80")]
[TestCase("https://localhost:80")]
[TestCase("localhost")]
[TestCase("localhost:8080")]
[TestCase("http://110.10.10.10")]
[TestCase("110.10.10.10")]
[TestCase("110.10.10.10:8080")]
[TestCase("192.168.1.1")]
[TestCase("192.168.1.1:3000")]
[TestCase("ftp://110.10.10.10")]
[TestCase("[2001:db8::1]")]
[TestCase("[2001:db8::1]:8080")]
[TestCase("http://[2001:db8::1]")]
[TestCase("https://[2001:db8::1]:8080")]
[TestCase("[::1]")]
[TestCase("[::1]:8080")]
[TestCase("2001:db8::1")]
[TestCase("fe80:1:2::3:4")]
[TestCase("::1")]
[TestCase("HTTP://EXAMPLE.COM")]
[TestCase("HTTPS://EXAMPLE.COM")]
[TestCase("EXAMPLE.COM")]
[TestCase("LOCALHOST")]
[TestCase("example.com/path")]
[TestCase("example.com/path/to/resource")]
[TestCase("http://example.com/path")]
[TestCase("https://example.com/path?query=1")]
[TestCase("192.168.1.1/path/to/resource")]
[TestCase("192.168.1.1/path/to/resource?query=1")]
[TestCase("localhost:8080/api/endpoint")]
[TestCase("http://localhost/path")]
[TestCase("[::1]/path")]
[TestCase("[2001:db8::1]/path?query=1")]
public void WhenValidUrlThenIsUrlReturnsTrue(string url)
{
Assert.That(plugin.IsURL(url), Is.True);
}
[TestCase("2001:db8::1/path")]
[TestCase("wwww")]
[TestCase("wwww.c")]
[TestCase("not a url")]
[TestCase("just text")]
[TestCase("http://")]
[TestCase("://example.com")]
[TestCase("0.0.0.0")] // Pattern excludes 0.0.0.0
[TestCase("256.1.1.1")] // Invalid IPv4
[TestCase("example")] // No TLD
[TestCase(".com")]
[TestCase("http://.com")]
public void WhenInvalidUrlThenIsUrlReturnsFalse(string url)
{
Assert.That(plugin.IsURL(url), Is.False);
}
}
}

View file

@ -1,6 +1,7 @@
using System;
using System;
using System.Collections.Generic;
using System.Text.RegularExpressions;
using System.Linq;
using System.Net;
using System.Windows.Controls;
using Flow.Launcher.Plugin.SharedCommands;
@ -8,49 +9,29 @@ namespace Flow.Launcher.Plugin.Url
{
public class Main : IPlugin, IPluginI18n, ISettingProvider
{
//based on https://gist.github.com/dperini/729294
private const string UrlPattern = "^" +
// protocol identifier
"(?:(?:https?|ftp)://|)" +
// user:pass authentication
"(?:\\S+(?::\\S*)?@)?" +
"(?:" +
// IP address exclusion
// private & local networks
"(?!(?:10|127)(?:\\.\\d{1,3}){3})" +
"(?!(?:169\\.254|192\\.168)(?:\\.\\d{1,3}){2})" +
"(?!172\\.(?:1[6-9]|2\\d|3[0-1])(?:\\.\\d{1,3}){2})" +
// IP address dotted notation octets
// excludes loopback network 0.0.0.0
// excludes reserved space >= 224.0.0.0
// excludes network & broacast addresses
// (first & last IP address of each class)
"(?:[1-9]\\d?|1\\d\\d|2[01]\\d|22[0-3])" +
"(?:\\.(?:1?\\d{1,2}|2[0-4]\\d|25[0-5])){2}" +
"(?:\\.(?:[1-9]\\d?|1\\d\\d|2[0-4]\\d|25[0-4]))" +
"|" +
// host name
"(?:(?:[a-z\\u00a1-\\uffff0-9]-*)*[a-z\\u00a1-\\uffff0-9]+)" +
// domain name
"(?:\\.(?:[a-z\\u00a1-\\uffff0-9]-*)*[a-z\\u00a1-\\uffff0-9]+)*" +
// TLD identifier
"(?:\\.(?:[a-z\\u00a1-\\uffff]{2,}))" +
")" +
// port number
"(?::\\d{2,5})?" +
// resource path
"(?:/\\S*)?" +
"$";
private readonly Regex UrlRegex = new(UrlPattern, RegexOptions.Compiled | RegexOptions.IgnoreCase);
internal static PluginInitContext Context { get; private set; }
internal static Settings Settings { get; private set; }
private static readonly string[] UrlSchemes = ["http://", "https://", "ftp://"];
public List<Result> Query(Query query)
{
var raw = query.Search;
if (IsURL(raw))
if (!IsURL(raw))
{
return
return [];
}
if (IPEndPoint.TryParse(raw, out var endpoint))
{
if (endpoint.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6 && raw[0] != '[' && raw[^1] != ']')
{
// Enclose IPv6 addresses in brackets for URL formatting
raw = $"[{raw}]";
}
}
return
[
new()
{
@ -60,7 +41,8 @@ namespace Flow.Launcher.Plugin.Url
Score = 8,
Action = _ =>
{
if (!raw.StartsWith("http://", StringComparison.OrdinalIgnoreCase) && !raw.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
// not a recognized scheme, add preferred http scheme
if (!UrlSchemes.Any(scheme => raw.StartsWith(scheme, StringComparison.OrdinalIgnoreCase)))
{
raw = GetHttpPreference() + "://" + raw;
}
@ -92,9 +74,6 @@ namespace Flow.Launcher.Plugin.Url
}
}
];
}
return [];
}
private static string GetHttpPreference()
@ -104,19 +83,68 @@ namespace Flow.Launcher.Plugin.Url
public bool IsURL(string raw)
{
raw = raw.ToLower();
if (string.IsNullOrWhiteSpace(raw))
return false;
if (UrlRegex.Match(raw).Value == raw) return true;
var input = raw.Trim();
if (raw == "localhost" || raw.StartsWith("localhost:") ||
raw == "http://localhost" || raw.StartsWith("http://localhost:") ||
raw == "https://localhost" || raw.StartsWith("https://localhost:")
)
// Exclude numbers (e.g. 1.2345)
if (decimal.TryParse(input, System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out _))
return false;
// Check if it's a bare IP address with optional port, path, query, or fragment
var ipPart = input.Split('/', '?', '#')[0]; // Remove path, query, and fragment
if (IPEndPoint.TryParse(ipPart, out var endpoint))
{
switch (endpoint.AddressFamily)
{
case System.Net.Sockets.AddressFamily.InterNetwork:
return !endpoint.Address.Equals(IPAddress.Any);
case System.Net.Sockets.AddressFamily.InterNetworkV6:
if (input.Contains('/') || input.Contains('?') || input.Contains('#'))
{
// Check if IPv6 address is properly bracketed
var bracketStart = input.IndexOf('[');
var bracketEnd = input.IndexOf(']');
if (bracketStart == -1 || bracketEnd == -1 || bracketStart > bracketEnd)
return false;
}
return !endpoint.Address.Equals(IPAddress.IPv6Any);
}
return true;
}
return false;
// Add protocol if missing for Uri validation
var urlToValidate = UrlSchemes.Any(s => input.StartsWith(s, StringComparison.OrdinalIgnoreCase))
? input
: GetHttpPreference() + "://" + input;
if (!Uri.TryCreate(urlToValidate, UriKind.Absolute, out var uri))
return false;
// Validate protocol
if (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps && uri.Scheme != Uri.UriSchemeFtp)
return false;
var host = uri.Host;
// localhost is valid
if (host.Equals("localhost", StringComparison.OrdinalIgnoreCase))
return true;
// Valid IP address (excluding 0.0.0.0)
if (IPEndPoint.TryParse(host, out endpoint))
return !endpoint.Address.Equals(IPAddress.Any) && !endpoint.Address.Equals(IPAddress.IPv6Any);
// Domain must have valid format with TLD
var parts = host.Split('.');
if (parts.Length < 2 || parts.Any(string.IsNullOrEmpty))
return false;
// TLD must be at least 2 characters, allowing letters and digits
var tld = parts[^1];
return tld.Length >= 2 && tld.All(char.IsLetterOrDigit);
}
public void Init(PluginInitContext context)