Base64 subscription fetcher works, some ui changes
This commit is contained in:
@@ -12,8 +12,9 @@ public static class FlagEmojiParser
|
||||
/// <summary>
|
||||
/// Parses a string and returns a list of ISO 3166-1 alpha-2 country codes found as emojis.
|
||||
/// </summary>
|
||||
public static List<string> ExtractCountryCodes(string text)
|
||||
public static List<string> ExtractCountryCodes(string text, out List<string> countryIndicators)
|
||||
{
|
||||
countryIndicators = [];
|
||||
var countryCodes = new List<string>();
|
||||
if (string.IsNullOrEmpty(text)) return countryCodes;
|
||||
|
||||
@@ -45,6 +46,7 @@ public static class FlagEmojiParser
|
||||
var c2 = (char)('A' + letterOffset);
|
||||
|
||||
countryCodes.Add($"{c1}{c2}");
|
||||
countryIndicators.Add($"{char.ConvertFromUtf32(RegionalIndicatorStart + firstLetterCode.Value)}{char.ConvertFromUtf32(RegionalIndicatorStart + letterOffset)}");
|
||||
|
||||
// Reset to look for the next flag
|
||||
firstLetterCode = null;
|
||||
@@ -59,4 +61,9 @@ public static class FlagEmojiParser
|
||||
|
||||
return countryCodes;
|
||||
}
|
||||
|
||||
public static List<string> ExtractCountryCodes(string text)
|
||||
{
|
||||
return ExtractCountryCodes(text, out _);
|
||||
}
|
||||
}
|
||||
@@ -34,4 +34,14 @@ public abstract class Server
|
||||
{
|
||||
get; set;
|
||||
} = string.Empty;
|
||||
|
||||
public bool IsDeleted
|
||||
{
|
||||
get; set;
|
||||
} = false;
|
||||
|
||||
public bool IsDisabled
|
||||
{
|
||||
get; set;
|
||||
} = false;
|
||||
}
|
||||
|
||||
@@ -54,9 +54,29 @@ public class Subscription
|
||||
get; set;
|
||||
} = string.Empty;
|
||||
|
||||
|
||||
public List<SubscriptionServer> Servers
|
||||
{
|
||||
get; set;
|
||||
} = [];
|
||||
|
||||
public long Upload
|
||||
{
|
||||
get; set;
|
||||
} = 0;
|
||||
|
||||
public long Download
|
||||
{
|
||||
get; set;
|
||||
} = 0;
|
||||
|
||||
public long Total
|
||||
{
|
||||
get; set;
|
||||
} = 0;
|
||||
|
||||
public DateTime ExpireDate
|
||||
{
|
||||
get; set;
|
||||
} = DateTime.MaxValue;
|
||||
|
||||
}
|
||||
|
||||
@@ -1,14 +1,22 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using WinWARP.Core.Helper;
|
||||
using WinWARP.Core.Models.Entities;
|
||||
using WinWARP.Core.Services.Interfaces;
|
||||
using static System.Runtime.InteropServices.JavaScript.JSType;
|
||||
using static Microsoft.EntityFrameworkCore.DbLoggerCategory.Database;
|
||||
|
||||
namespace WinWARP.Core.Services.Implementations;
|
||||
|
||||
public class SubscriptionFetcher : ISubscriptionFetcher
|
||||
public partial class SubscriptionFetcher(IDialogService dialogService) : ISubscriptionFetcher
|
||||
{
|
||||
private static readonly HttpClient _httpClient = new HttpClient();
|
||||
private readonly IDialogService _dialogService = dialogService;
|
||||
|
||||
public bool IsUrlValid(string subscriptionUrl)
|
||||
{
|
||||
Uri? validatedUri;
|
||||
@@ -23,13 +31,334 @@ public class SubscriptionFetcher : ISubscriptionFetcher
|
||||
|
||||
public async Task<Subscription> FetchAsync(string subscriptionUrl)
|
||||
{
|
||||
// TODO: Implement the actual fetching logic here, e.g., using HttpClient to get the subscription data from the URL.
|
||||
await Task.Delay(10); // Simulate an asynchronous operation
|
||||
return new Subscription
|
||||
_httpClient.Timeout = TimeSpan.FromSeconds(10);
|
||||
var subscription = new Subscription
|
||||
{
|
||||
DefaultTitle = "Default Subscription",
|
||||
Link = subscriptionUrl,
|
||||
Servers = [new() { DefaultTitle = "Default Server", Region = "NO", Title = "Not default title" }]
|
||||
Link = subscriptionUrl
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
using var response = await _httpClient.GetAsync(subscriptionUrl);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
response.Headers.TryGetValues("Profile-Web-Page-Url", out var profileWebPage);
|
||||
subscription.WebPage = profileWebPage?.FirstOrDefault() ?? string.Empty;
|
||||
|
||||
response.Headers.TryGetValues("Profile-Title", out var profileTitle);
|
||||
subscription.DefaultTitle = GetSubscriptionTitle(profileTitle?.FirstOrDefault());
|
||||
|
||||
response.Headers.TryGetValues("Profile-Update-Interval", out var profileUpdateInterval);
|
||||
if (profileUpdateInterval != null && int.TryParse(profileUpdateInterval.FirstOrDefault(), out var updateInterval)) subscription.DefaultUpdateInterval = updateInterval;
|
||||
|
||||
response.Headers.TryGetValues("Profile-Support-Url", out var profileSupportUrl);
|
||||
subscription.SupportURL = profileSupportUrl?.FirstOrDefault() ?? string.Empty;
|
||||
|
||||
response.Headers.TryGetValues("Subscription-Userinfo", out var profileNetworkData);
|
||||
var (Upload, Download, Total, Expire) = ParseNetworkData(profileNetworkData?.FirstOrDefault() ?? string.Empty);
|
||||
subscription.Upload = Upload;
|
||||
subscription.Download = Download;
|
||||
subscription.Total = Total;
|
||||
subscription.ExpireDate = Expire;
|
||||
|
||||
subscription.Servers = ParseServerData(await response.Content.ReadAsStringAsync());
|
||||
}
|
||||
catch (Exception ex) when (!Debugger.IsAttached)
|
||||
{
|
||||
_dialogService.ShowErrorAsync(ex.Message);
|
||||
}
|
||||
|
||||
return subscription;
|
||||
}
|
||||
|
||||
#region Titles
|
||||
private static string GetSubscriptionTitle(string title)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(title))
|
||||
{
|
||||
return "New subscription";
|
||||
}
|
||||
else if (title.StartsWith("base64:"))
|
||||
{
|
||||
var base64Data = title[7..];
|
||||
try
|
||||
{
|
||||
var stringData = Encoding.UTF8.GetString(Convert.FromBase64String(base64Data));
|
||||
|
||||
return stringData[..(Math.Min(stringData.Length, 100))];
|
||||
}
|
||||
catch
|
||||
{
|
||||
return "New subscription";
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
return title[..(Math.Min(title.Length, 100))];
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetServerTitle(string title, out string region)
|
||||
{
|
||||
if (string.IsNullOrEmpty(title))
|
||||
{
|
||||
region = string.Empty;
|
||||
return "New server";
|
||||
}
|
||||
|
||||
title = title[1..]; // skip the leading '#' character
|
||||
|
||||
region = FlagEmojiParser.ExtractCountryCodes(title, out var countryIndicators).FirstOrDefault() ?? string.Empty;
|
||||
if (!string.IsNullOrEmpty(region))
|
||||
{
|
||||
title = title.Replace(countryIndicators.FirstOrDefault() ?? string.Empty, string.Empty).Trim();
|
||||
return title[..Math.Min(title.Length, 100)];
|
||||
}
|
||||
return title[..Math.Min(title.Length, 100)];
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region SubscriptionParsing
|
||||
private static (long Upload, long Download, long Total, DateTime Expire) ParseNetworkData(string data)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(data))
|
||||
throw new ArgumentException("Input string cannot be null or empty.", nameof(data));
|
||||
|
||||
// Dictionary to temporarily store the parsed key-value pairs
|
||||
var values = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Split the string by semicolon to get individual pairs (e.g., "upload=0")
|
||||
var pairs = data.Split(';', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
|
||||
foreach (var keyValue in from string pair in pairs
|
||||
let keyValue = pair.Split('=', 2)
|
||||
where keyValue.Length == 2
|
||||
select keyValue)
|
||||
{
|
||||
values[keyValue[0].Trim()] = keyValue[1].Trim();
|
||||
}
|
||||
|
||||
// Helper to safely parse numbers, defaulting to 0 if missing or invalid
|
||||
long GetLongValue(string key) =>
|
||||
values.TryGetValue(key, out var val) && long.TryParse(val, out var result) ? result : 0;
|
||||
|
||||
var upload = GetLongValue("upload");
|
||||
var download = GetLongValue("download");
|
||||
var total = GetLongValue("total");
|
||||
var expireUnix = GetLongValue("expire");
|
||||
|
||||
var expireDateTime = DateTimeOffset.FromUnixTimeSeconds(expireUnix).UtcDateTime;
|
||||
|
||||
return (upload, download, total, expireDateTime);
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"^[A-Za-z0-9+/]*={0,2}$")]
|
||||
private static partial Regex Base64Regex();
|
||||
|
||||
private static List<SubscriptionServer> ParseServerData(string data)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(data)) return [];
|
||||
|
||||
data = data.Trim();
|
||||
|
||||
if (TryGetBase64String(data, out var decodedText))
|
||||
{
|
||||
return [.. ParseText(decodedText)];
|
||||
}
|
||||
else if (data.StartsWith('{') || data.StartsWith('['))
|
||||
{
|
||||
return ParseJson(data);
|
||||
}
|
||||
else if (data.StartsWith("---") || data.Contains(':'))
|
||||
{
|
||||
return ParseYaml(data);
|
||||
}
|
||||
else
|
||||
{
|
||||
return [.. ParseText(data)];
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region ServersParsing
|
||||
private static List<SubscriptionServer> ParseJson(string jsonData)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
private static List<SubscriptionServer> ParseYaml(string yamlData)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
private static IEnumerable<SubscriptionServer> ParseText(string textData)
|
||||
{
|
||||
foreach (var line in textData.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
var server = ParseServerFromLink(line);
|
||||
if (server != null)
|
||||
{
|
||||
yield return server;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryGetBase64String(string input, out string decodedText)
|
||||
{
|
||||
decodedText = null;
|
||||
|
||||
// Sanitize edge-case variants: URL-safe Base64 fixes ('-' -> '+', '_' -> '/')
|
||||
var sanitized = input.Replace('-', '+').Replace('_', '/').Replace("\r", "").Replace("\n", "").Trim();
|
||||
|
||||
// Fix missing padding if client/server skipped it
|
||||
var mod = sanitized.Length % 4;
|
||||
if (mod > 0) sanitized += new string('=', 4 - mod);
|
||||
|
||||
// Regex validation for pure Base64 characters
|
||||
if (!Base64Regex().IsMatch(sanitized)) return false;
|
||||
|
||||
try
|
||||
{
|
||||
var bytes = Convert.FromBase64String(sanitized);
|
||||
decodedText = Encoding.UTF8.GetString(bytes);
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static SubscriptionServer ParseServerFromLink(string serverData)
|
||||
{
|
||||
var uri = new Uri(serverData);
|
||||
|
||||
if (uri.Scheme == "ss" || uri.Scheme == "vmess" || uri.Scheme == "vless" || uri.Scheme == "trojan")
|
||||
{
|
||||
var title = Uri.UnescapeDataString(uri.Fragment);
|
||||
return new SubscriptionServer
|
||||
{
|
||||
DefaultTitle = GetServerTitle(title, out var region),
|
||||
Region = FlagEmojiParser.ExtractCountryCodes(title).FirstOrDefault() ?? string.Empty,
|
||||
ConnectionString = serverData
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Tags
|
||||
public List<string> GetTags(string connection)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(connection)) return null;
|
||||
|
||||
if (connection.StartsWith('{') || connection.StartsWith('[')) return [.. GetJsonTags(connection)];
|
||||
else return [.. GetTextTags(connection)];
|
||||
}
|
||||
|
||||
private static IEnumerable<string> GetTextTags(string connection)
|
||||
{
|
||||
var input = connection.Trim();
|
||||
var scheme = string.Empty;
|
||||
var query = string.Empty;
|
||||
var lowered = input.ToLowerInvariant();
|
||||
|
||||
if (Uri.TryCreate(input, UriKind.Absolute, out var uri))
|
||||
{
|
||||
scheme = uri.Scheme ?? string.Empty;
|
||||
query = uri.Query ?? string.Empty;
|
||||
}
|
||||
else
|
||||
{
|
||||
var m = UrlCheck().Match(input);
|
||||
if (m.Success)
|
||||
{
|
||||
scheme = m.Groups[1].Value;
|
||||
var idx = input.IndexOf('?');
|
||||
if (idx >= 0) query = input[idx..];
|
||||
}
|
||||
}
|
||||
|
||||
// 1) Protocol tag (Uri.Scheme)
|
||||
if (!string.IsNullOrEmpty(scheme))
|
||||
yield return scheme.ToUpperInvariant();
|
||||
|
||||
// 2) Security tag (one of: reality, vision, tls)
|
||||
var security = string.Empty;
|
||||
if (lowered.Contains("reality") || scheme.Contains("reality", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
security = "reality";
|
||||
}
|
||||
else if (lowered.Contains("vision") || scheme.Contains("vision", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
security = "vision";
|
||||
}
|
||||
else if (scheme.EndsWith("+tls", StringComparison.OrdinalIgnoreCase) ||
|
||||
scheme.Equals("https", StringComparison.OrdinalIgnoreCase) ||
|
||||
scheme.Equals("wss", StringComparison.OrdinalIgnoreCase) ||
|
||||
lowered.Contains("tls") ||
|
||||
lowered.Contains("secure=true") ||
|
||||
SecurityCheck().IsMatch(query))
|
||||
{
|
||||
security = "tls";
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(security))
|
||||
yield return security.ToUpperInvariant();
|
||||
|
||||
// 3) Transport tag (one of: tcp, ws, grpc, h2, xhttp)
|
||||
var transport = string.Empty;
|
||||
|
||||
if (scheme.Equals("ws", StringComparison.OrdinalIgnoreCase) ||
|
||||
scheme.Equals("wss", StringComparison.OrdinalIgnoreCase) ||
|
||||
lowered.Contains("transport=ws") ||
|
||||
lowered.Contains("websocket"))
|
||||
{
|
||||
transport = "ws";
|
||||
}
|
||||
else if (scheme.Contains("grpc", StringComparison.OrdinalIgnoreCase) ||
|
||||
lowered.Contains("transport=grpc") ||
|
||||
lowered.Contains("grpc/"))
|
||||
{
|
||||
transport = "grpc";
|
||||
}
|
||||
else if (scheme.Equals("h2", StringComparison.OrdinalIgnoreCase) ||
|
||||
lowered.Contains("http2") ||
|
||||
lowered.Contains("alpn=h2") ||
|
||||
lowered.Contains("transport=h2"))
|
||||
{
|
||||
transport = "h2";
|
||||
}
|
||||
else if (lowered.Contains("xhttp") || lowered.Contains("x-http") || lowered.Contains("transport=xhttp"))
|
||||
{
|
||||
transport = "xhttp";
|
||||
}
|
||||
else if (scheme.Equals("tcp", StringComparison.OrdinalIgnoreCase) ||
|
||||
lowered.Contains("transport=tcp") ||
|
||||
lowered.Contains("tcp/") ||
|
||||
lowered.Contains(':'))
|
||||
{
|
||||
transport = "tcp";
|
||||
}
|
||||
else
|
||||
{
|
||||
transport = "tcp";
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(transport))
|
||||
yield return transport.ToUpperInvariant();
|
||||
}
|
||||
|
||||
private IEnumerable<string> GetJsonTags(string json)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"^([a-zA-Z][\w+\-\.]*):")]
|
||||
private static partial Regex UrlCheck();
|
||||
[GeneratedRegex(@"(\b|&)security=(tls|true)(&|$)", RegexOptions.IgnoreCase, "en-001")]
|
||||
private static partial Regex SecurityCheck();
|
||||
#endregion
|
||||
}
|
||||
|
||||
@@ -8,4 +8,5 @@ namespace WinWARP.Core.Services.Interfaces;
|
||||
public interface IDialogService
|
||||
{
|
||||
Task<(CommonDialogResult Result, string Title, string URL, int UpdateInterval)> ShowAddSubscriptionAsync();
|
||||
Task ShowErrorAsync(string message);
|
||||
}
|
||||
|
||||
@@ -9,4 +9,5 @@ public interface ISubscriptionFetcher
|
||||
{
|
||||
public bool IsUrlValid(string url);
|
||||
public Task<Subscription> FetchAsync(string url);
|
||||
public List<string> GetTags(string connectionString);
|
||||
}
|
||||
|
||||
@@ -69,7 +69,9 @@ public partial class SubsSettingsViewModel(ISubscriptionDbService dbService, ISu
|
||||
Subscriptions.Add(new SubscriptionViewModel(subscription));
|
||||
foreach (var server in subscription.Servers)
|
||||
{
|
||||
Servers.Add(new SubscriptionServerViewModel(server) { Tags = { "VLESS", "WS", "TLS" } });
|
||||
var serverVM = new SubscriptionServerViewModel(server);
|
||||
foreach (var tag in _fetcher.GetTags(server.ConnectionString)) serverVM.Tags.Add(tag);
|
||||
Servers.Add(serverVM);
|
||||
}
|
||||
ShowLoadingIndicator = false;
|
||||
}
|
||||
|
||||
@@ -14,14 +14,10 @@ using WinWARP.Ui.Views.Dialogs;
|
||||
|
||||
namespace WinWARP.Ui.Services;
|
||||
|
||||
public class DialogService : IDialogService
|
||||
public class DialogService(XamlRootTracker tracker, ILocalizationService localizationService) : IDialogService
|
||||
{
|
||||
private readonly XamlRootTracker _tracker;
|
||||
|
||||
public DialogService(XamlRootTracker tracker)
|
||||
{
|
||||
_tracker = tracker;
|
||||
}
|
||||
private readonly XamlRootTracker _tracker = tracker;
|
||||
private readonly ILocalizationService _localizationService = localizationService;
|
||||
|
||||
public async Task<(CommonDialogResult Result, string Title, string URL, int UpdateInterval)> ShowAddSubscriptionAsync()
|
||||
{
|
||||
@@ -44,4 +40,17 @@ public class DialogService : IDialogService
|
||||
|
||||
return (mappedResult, dialog.ViewModel.CustomName, dialog.ViewModel.SubscriptionLink, dialog.ViewModel.UpdateInterval);
|
||||
}
|
||||
|
||||
public async Task ShowErrorAsync(string message)
|
||||
{
|
||||
var dialog = new ContentDialog
|
||||
{
|
||||
Title = _localizationService.GetString("ErrorDialog.Title"),
|
||||
Content = message,
|
||||
CloseButtonText = _localizationService.GetString("ErrorDialog.CloseButtonText"),
|
||||
XamlRoot = _tracker.XamlRoot,
|
||||
Style = Application.Current.Resources["DefaultContentDialogStyle"] as Style
|
||||
};
|
||||
await dialog.ShowAsync();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -267,4 +267,10 @@
|
||||
<data name="UpdateInterval_auto" xml:space="preserve">
|
||||
<value>Auto</value>
|
||||
</data>
|
||||
<data name="ErrorDialog.CloseButtonText" xml:space="preserve">
|
||||
<value>OK</value>
|
||||
</data>
|
||||
<data name="ErrorDialog.Title" xml:space="preserve">
|
||||
<value>Error occurred:</value>
|
||||
</data>
|
||||
</root>
|
||||
@@ -267,4 +267,10 @@
|
||||
<data name="UpdateInterval_auto" xml:space="preserve">
|
||||
<value>Автоматически</value>
|
||||
</data>
|
||||
<data name="ErrorDialog.CloseButtonText" xml:space="preserve">
|
||||
<value>Ок</value>
|
||||
</data>
|
||||
<data name="ErrorDialog.Title" xml:space="preserve">
|
||||
<value>Произошла ошибка:</value>
|
||||
</data>
|
||||
</root>
|
||||
@@ -31,7 +31,9 @@
|
||||
</MenuFlyout>
|
||||
</UserControl.ContextFlyout>
|
||||
|
||||
<Grid MaxHeight="64">
|
||||
<Grid MaxHeight="64"
|
||||
Background="{ThemeResource CardBackgroundFillColorDefaultBrush}"
|
||||
CornerRadius="4">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="64"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
@@ -76,7 +78,7 @@
|
||||
</ItemsView.ItemTemplate>
|
||||
</ItemsView>
|
||||
<!--<TextBlock Grid.Column="1" Text="both server"/>-->
|
||||
<!--<ContentPresenter Grid.Column="1" Grid.Row="1" Content="{x:Bind ServerData}" ContentTemplate="{x:Bind ChooseTemplate(ServerData), Mode=OneWay}"/>-->
|
||||
<ContentPresenter Grid.Column="1" Grid.Row="1" Content="{x:Bind ServerData}" ContentTemplate="{x:Bind ChooseTemplate(ServerData), Mode=OneWay}"/>
|
||||
</Grid>
|
||||
|
||||
</UserControl>
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
|
||||
mc:Ignorable="d">
|
||||
|
||||
<ScrollView>
|
||||
<StackPanel>
|
||||
<Button Command="{x:Bind ViewModel.AddSubscriptionCommand}" Content="Add Subscription"/>
|
||||
<GridView ItemsSource="{x:Bind ViewModel.Servers}" SelectionMode="None">
|
||||
@@ -28,4 +29,6 @@
|
||||
</GridView>
|
||||
<ProgressRing IsActive="{x:Bind ViewModel.ShowLoadingIndicator, Mode=OneWay}"/>
|
||||
</StackPanel>
|
||||
|
||||
</ScrollView>
|
||||
</Page>
|
||||
|
||||
@@ -127,6 +127,7 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Folder Include="ViewModels\" />
|
||||
<Folder Include="Views\ConfigSettings\" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user