* Inner uri import and export

* Add tests

* Fix

* Compress export length
This commit is contained in:
DHR60
2026-05-06 12:33:35 +00:00
committed by GitHub
parent d13f7a4db6
commit 75ea81dd69
17 changed files with 468 additions and 0 deletions
@@ -0,0 +1,37 @@
using AwesomeAssertions;
using ServiceLib.Enums;
using ServiceLib.Handler.Fmt;
using ServiceLib.Tests.CoreConfig;
using Xunit;
namespace ServiceLib.Tests.Fmt;
public class InnerFmtTests
{
[Fact]
public void ToUriAndResolve_ShouldRoundTripPolicyGroupReferences()
{
var childA = CoreConfigTestFactory.CreateSocksNode(ECoreType.Xray, "child-a", "child-a");
var childB = CoreConfigTestFactory.CreateVmessNode(ECoreType.Xray, "child-b", "child-b");
var group = CoreConfigTestFactory.CreatePolicyGroupNode(ECoreType.Xray, "group-1", "group-1",
[childA.IndexId, childB.IndexId]);
group.SetProtocolExtra(group.GetProtocolExtra() with { SubChildItems = "original-sub" });
var uri = InnerFmt.ToUri([group, childA, childB]);
uri.Should().NotBeNullOrWhiteSpace();
var resolved = InnerFmt.Resolve(uri!, "sub-123");
resolved.Should().NotBeNull();
resolved.Should().HaveCount(3);
var resolvedGroup = resolved!.Single(x => x.Remarks == group.Remarks);
var resolvedChildA = resolved.Single(x => x.Remarks == childA.Remarks);
var resolvedChildB = resolved.Single(x => x.Remarks == childB.Remarks);
resolvedGroup.ConfigType.Should().Be(EConfigType.PolicyGroup);
resolvedGroup.GetProtocolExtra().SubChildItems.Should().Be("sub-123");
resolvedGroup.GetProtocolExtra().ChildItems.Should().Be($"{resolvedChildA.IndexId},{resolvedChildB.IndexId}");
}
}
+1
View File
@@ -64,6 +64,7 @@ public class Global
public const string HttpsProtocol = "https://";
public const string SocksProtocol = "socks://";
public const string Socks5Protocol = "socks5://";
public const string InnerUriProtocol = "v2rayn://";
public const string AsIs = "AsIs";
public const string IPIfNonMatch = "IPIfNonMatch";
public const string IPOnDemand = "IPOnDemand";
@@ -1767,6 +1767,55 @@ public static class ConfigHandler
return -1;
}
private static async Task<int> AddBatchServers4InnerUri(Config config, string strData, string subid, bool isSub)
{
if (strData.IsNullOrEmpty())
{
return -1;
}
if (isSub && subid.IsNotEmpty())
{
await RemoveServersViaSubid(config, subid, isSub);
}
var lstServer = InnerFmt.Resolve(strData, subid);
if (lstServer?.Count > 0)
{
var counter = 0;
foreach (var profileItem in lstServer)
{
profileItem.Subid = subid;
profileItem.IsSub = isSub;
var addStatus = profileItem.ConfigType switch
{
EConfigType.VMess => await AddVMessServer(config, profileItem),
EConfigType.Shadowsocks => await AddShadowsocksServer(config, profileItem),
EConfigType.HTTP => await AddHttpServer(config, profileItem),
EConfigType.SOCKS => await AddSocksServer(config, profileItem),
EConfigType.Trojan => await AddTrojanServer(config, profileItem),
EConfigType.VLESS => await AddVlessServer(config, profileItem),
EConfigType.Hysteria2 => await AddHysteria2Server(config, profileItem),
EConfigType.TUIC => await AddTuicServer(config, profileItem),
EConfigType.WireGuard => await AddWireguardServer(config, profileItem),
EConfigType.Anytls => await AddAnytlsServer(config, profileItem),
EConfigType.Naive => await AddNaiveServer(config, profileItem),
EConfigType.PolicyGroup or EConfigType.ProxyChain => await AddServerCommon(config, profileItem),
_ => -1,
};
if (addStatus == 0)
{
counter++;
}
}
await SaveConfig(config);
return counter;
}
return -1;
}
/// <summary>
/// Main entry point for adding batch servers from various formats
/// Tries different parsing methods to import as many servers as possible
@@ -1815,6 +1864,20 @@ public static class ConfigHandler
counter = await AddBatchServers4Wireguard(config, strData, subid, isSub);
}
//May be standard uri mixed with internal uri
var innerUriCount = await AddBatchServers4InnerUri(config, strData, subid, isSub);
if (innerUriCount > 0)
{
if (counter > 0)
{
counter += innerUriCount;
}
else
{
counter = innerUriCount;
}
}
//maybe other sub
if (counter < 1)
{
+299
View File
@@ -0,0 +1,299 @@
namespace ServiceLib.Handler.Fmt;
public class InnerFmt
{
private static readonly Lazy<string> SessionSalt = new(() => Utils.GetGuid(false));
public static List<ProfileItem>? Resolve(string strData, string subid)
{
var list = new List<ProfileItem>();
// Overwrite externally imported indexIds to avoid possible sources of attacks
var indexIdMap = new Dictionary<string, string>();
using (var reader = new StringReader(strData))
{
while (reader.ReadLine() is { } line)
{
if (line.IsNullOrEmpty())
{
continue;
}
var trimmedLine = line.Trim();
if (!line.StartsWith(Global.InnerUriProtocol, StringComparison.OrdinalIgnoreCase))
{
continue;
}
var profileItem = ResolveSingle(trimmedLine);
if (profileItem is null)
{
continue;
}
if (profileItem.ConfigType == EConfigType.Custom)
{
// Unsupported, also to avoid possible sources of attacks, skip it
continue;
}
// overwrite indexId
var newIndexId = Utils.GetGuid(false);
if (!profileItem.IndexId.IsNullOrEmpty())
{
// Ignore duplicated indexId
indexIdMap[profileItem.IndexId] = newIndexId;
}
profileItem.IndexId = newIndexId;
list.Add(profileItem);
}
}
// For group-type profile items, also overwrite the ChildItems and ChildSubId
var emptyGroupProfileList = new List<ProfileItem>();
foreach (var item in list.Where(i => i.ConfigType.IsGroupType()))
{
var protocolExtra = item.GetProtocolExtra();
// Only allow "self" as a special value for SubChildItems to avoid possible sources of attacks,
// which means it will be replaced with the subid, otherwise set it to null
//if (!protocolExtra.SubChildItems.IsNullOrEmpty())
if (protocolExtra.SubChildItems == "self")
{
protocolExtra = protocolExtra with
{
SubChildItems = subid
};
}
else
{
protocolExtra = protocolExtra with
{
SubChildItems = null
};
}
if (Utils.String2List(protocolExtra.ChildItems) is { Count: > 0 } childIndexIds)
{
var newChildIndexIds = childIndexIds
.Select(id => indexIdMap.GetValueOrDefault(id, null))
.Where(id => !id.IsNullOrEmpty())
.ToList();
protocolExtra = protocolExtra with
{
ChildItems = Utils.List2String(newChildIndexIds)
};
}
else
{
protocolExtra = protocolExtra with
{
ChildItems = null
};
}
item.SetProtocolExtra(protocolExtra);
if (protocolExtra.SubChildItems.IsNullOrEmpty()
&& protocolExtra.ChildItems.IsNullOrEmpty())
{
emptyGroupProfileList.Add(item);
}
}
// Remove empty group profile items
list.RemoveAll(emptyGroupProfileList.Contains);
return list;
}
public static string? ToUri(List<ProfileItem> items)
{
var sb = new StringBuilder();
foreach (var item in items)
{
if (item.ConfigType == EConfigType.Custom)
{
continue;
}
var itemClone = JsonUtils.DeepCopy(item);
if (itemClone is null)
{
continue;
}
// overwrite indexId
var originalIndexId = itemClone.IndexId;
var newIndexId = GetReproducibleExportId(originalIndexId);
itemClone.IndexId = newIndexId;
if (itemClone.ConfigType.IsGroupType())
{
var protocolExtra = itemClone.GetProtocolExtra();
if (!protocolExtra.SubChildItems.IsNullOrEmpty())
{
protocolExtra = protocolExtra with
{
SubChildItems = "self"
};
}
if (Utils.String2List(protocolExtra.ChildItems) is { Count: > 0 } childIndexIds)
{
var newChildIndexIds = childIndexIds
.Select(GetReproducibleExportId)
.Where(id => !id.IsNullOrEmpty())
.ToList();
protocolExtra = protocolExtra with
{
ChildItems = Utils.List2String(newChildIndexIds)
};
}
itemClone.SetProtocolExtra(protocolExtra);
}
var uri = ToUriSingle(itemClone);
if (!uri.IsNullOrEmpty())
{
sb.AppendLine(uri);
}
}
return sb.Length > 0 ? sb.ToString() : null;
}
private static ProfileItem? ResolveSingle(string str)
{
// format: v2rayn://vless/{url-safe base64 encoded_string}
var parsedUri = Utils.TryUri(str);
if (parsedUri is null)
{
return null;
}
var segment = parsedUri.AbsolutePath.TrimStart('/');
var decodedResult = Utils.Base64Decode(segment);
var jsonNode = JsonUtils.ParseJson(decodedResult);
if (jsonNode is not JsonObject jsonObj)
{
return null;
}
// flatten
// move jsonObj.ProtoExtraObj to jsonObj.ProtoExtra (string)
// move jsonObj.TransportExtraObj to jsonObj.TransportExtra (string)
if (jsonObj.TryGetPropertyValue("ProtoExtraObj", out var protoExtraNode)
&& protoExtraNode is JsonObject protoExtraObj)
{
jsonObj["ProtoExtra"] = JsonUtils.Serialize(protoExtraObj, false);
jsonObj.Remove("ProtoExtraObj");
}
if (jsonObj.TryGetPropertyValue("TransportExtraObj", out var transportExtraNode)
&& transportExtraNode is JsonObject transportExtraObj)
{
jsonObj["TransportExtra"] = JsonUtils.Serialize(transportExtraObj, false);
jsonObj.Remove("TransportExtraObj");
}
var profileItem = JsonUtils.Deserialize<ProfileItem>(JsonUtils.Serialize(jsonObj, false));
if (profileItem is null)
{
return null;
}
if (profileItem.ConfigVersion != 4)
{
return null;
}
// Check Enum.IsDefined
if (!Enum.IsDefined(typeof(EConfigType), profileItem.ConfigType))
{
return null;
}
if (profileItem.CoreType is not (null or ECoreType.Xray or ECoreType.sing_box))
{
return null;
}
var protocolExtra = profileItem.GetProtocolExtra();
var multipleLoad = protocolExtra.MultipleLoad;
if (multipleLoad is not null && !Enum.IsDefined(typeof(EMultipleLoad), multipleLoad))
{
return null;
}
return profileItem;
}
private static string? ToUriSingle(ProfileItem item)
{
var jsonNode = JsonUtils.ParseJson(JsonUtils.Serialize(item, false));
if (jsonNode is not JsonObject jsonObj)
{
return null;
}
// unflatten
// move jsonObj.ProtoExtra (string) to jsonObj.ProtoExtraObj
// move jsonObj.TransportExtra (string) to jsonObj.TransportExtraObj
if (jsonObj.TryGetPropertyValue("ProtoExtra", out var protoExtraNode)
&& protoExtraNode is JsonValue protoExtraValue
&& protoExtraValue.TryGetValue<string>(out var protoExtraStr)
&& !protoExtraStr.IsNullOrEmpty()
&& JsonUtils.ParseJson(protoExtraStr) is JsonObject protoExtraObj)
{
jsonObj["ProtoExtraObj"] = protoExtraObj;
jsonObj.Remove("ProtoExtra");
}
if (jsonObj.TryGetPropertyValue("TransportExtra", out var transportExtraNode)
&& transportExtraNode is JsonValue transportExtraValue
&& transportExtraValue.TryGetValue<string>(out var transportExtraStr)
&& !transportExtraStr.IsNullOrEmpty()
&& JsonUtils.ParseJson(transportExtraStr) is JsonObject transportExtraObj)
{
jsonObj["TransportExtraObj"] = transportExtraObj;
jsonObj.Remove("TransportExtra");
}
// Remove empty properties to reduce the length of the exported string
RemoveEmptyJson(jsonObj);
var jsonStr = JsonUtils.Serialize(jsonObj, false);
var encodedStr = Utils.Base64Encode(jsonStr).Replace('+', '-').Replace('/', '_').Replace("=", "");
return $"{Global.InnerUriProtocol}{item.ConfigType.ToString().ToLower()}/{encodedStr}";
}
private static string GetReproducibleExportId(string originalIndexId)
{
if (originalIndexId.IsNullOrEmpty())
{
return originalIndexId;
}
var hash = HashCode.Combine(SessionSalt.Value, originalIndexId) & 0x7FFFFFFF;
var bytes = BitConverter.GetBytes(hash);
return Convert.ToBase64String(bytes).Replace("=", "");
}
private static void RemoveEmptyJson(JsonNode? node)
{
// ReSharper disable once ConvertIfStatementToSwitchStatement
if (node is JsonObject jsonObject)
{
var propertiesToRemove = new List<string>();
foreach (var property in jsonObject)
{
RemoveEmptyJson(property.Value);
if (IsEmpty(property.Value))
{
propertiesToRemove.Add(property.Key);
}
}
foreach (var key in propertiesToRemove)
{
jsonObject.Remove(key);
}
}
else if (node is JsonArray jsonArray)
{
for (var i = jsonArray.Count - 1; i >= 0; i--)
{
RemoveEmptyJson(jsonArray[i]);
if (IsEmpty(jsonArray[i]))
{
jsonArray.RemoveAt(i);
}
}
}
}
private static bool IsEmpty(JsonNode? node)
{
return node switch
{
null => true,
JsonValue value when value.TryGetValue<string>(out var str) => string.IsNullOrEmpty(str),
JsonObject obj => obj.Count == 0,
JsonArray arr => arr.Count == 0,
_ => false
};
}
}
+9
View File
@@ -1023,6 +1023,15 @@ namespace ServiceLib.Resx {
}
}
/// <summary>
/// 查找类似 Export v2rayN Internal Share Link to Clipboard 的本地化字符串。
/// </summary>
public static string menuExport2InnerUri {
get {
return ResourceManager.GetString("menuExport2InnerUri", resourceCulture);
}
}
/// <summary>
/// 查找类似 Export Share Link to Clipboard 的本地化字符串。
/// </summary>
+3
View File
@@ -1728,4 +1728,7 @@ The "Get Certificate" action may fail if a self-signed certificate is used or if
<data name="TbPreSharedKey" xml:space="preserve">
<value>PreSharedKey</value>
</data>
<data name="menuExport2InnerUri" xml:space="preserve">
<value>Export v2rayN Internal Share Link to Clipboard</value>
</data>
</root>
+3
View File
@@ -1725,4 +1725,7 @@ The "Get Certificate" action may fail if a self-signed certificate is used or if
<data name="TbPreSharedKey" xml:space="preserve">
<value>PreSharedKey</value>
</data>
<data name="menuExport2InnerUri" xml:space="preserve">
<value>Export v2rayN Internal Share Link to Clipboard</value>
</data>
</root>
+3
View File
@@ -1728,4 +1728,7 @@ The "Get Certificate" action may fail if a self-signed certificate is used or if
<data name="TbPreSharedKey" xml:space="preserve">
<value>PreSharedKey</value>
</data>
<data name="menuExport2InnerUri" xml:space="preserve">
<value>Export v2rayN Internal Share Link to Clipboard</value>
</data>
</root>
+3
View File
@@ -1728,4 +1728,7 @@ The "Get Certificate" action may fail if a self-signed certificate is used or if
<data name="TbPreSharedKey" xml:space="preserve">
<value>PreSharedKey</value>
</data>
<data name="menuExport2InnerUri" xml:space="preserve">
<value>Export v2rayN Internal Share Link to Clipboard</value>
</data>
</root>
+3
View File
@@ -1728,4 +1728,7 @@
<data name="TbPreSharedKey" xml:space="preserve">
<value>PreSharedKey</value>
</data>
<data name="menuExport2InnerUri" xml:space="preserve">
<value>Export v2rayN Internal Share Link to Clipboard</value>
</data>
</root>
@@ -1725,4 +1725,7 @@
<data name="TbPreSharedKey" xml:space="preserve">
<value>PreSharedKey</value>
</data>
<data name="menuExport2InnerUri" xml:space="preserve">
<value>导出 v2rayN 内部分享链接至剪贴板 (多选)</value>
</data>
</root>
@@ -1725,4 +1725,7 @@
<data name="TbPreSharedKey" xml:space="preserve">
<value>PreSharedKey</value>
</data>
<data name="menuExport2InnerUri" xml:space="preserve">
<value>Export v2rayN Internal Share Link to Clipboard</value>
</data>
</root>
@@ -72,6 +72,7 @@ public class ProfilesViewModel : MyReactiveObject
public ReactiveCommand<Unit, Unit> Export2ClientConfigClipboardCmd { get; }
public ReactiveCommand<Unit, Unit> Export2ShareUrlCmd { get; }
public ReactiveCommand<Unit, Unit> Export2ShareUrlBase64Cmd { get; }
public ReactiveCommand<Unit, Unit> Export2InnerUriCmd { get; }
public ReactiveCommand<Unit, Unit> AddSubCmd { get; }
public ReactiveCommand<Unit, Unit> EditSubCmd { get; }
@@ -212,6 +213,10 @@ public class ProfilesViewModel : MyReactiveObject
{
await Export2ShareUrlAsync(true);
}, canEditRemove);
Export2InnerUriCmd = ReactiveCommand.CreateFromTask(async () =>
{
await Export2InnerUrlAsync();
}, canEditRemove);
//Subscription
AddSubCmd = ReactiveCommand.CreateFromTask(async () =>
@@ -840,6 +845,32 @@ public class ProfilesViewModel : MyReactiveObject
}
}
public async Task Export2InnerUrlAsync()
{
var lstSelected = await GetProfileItems(true);
if (lstSelected == null)
{
return;
}
var result = string.Empty;
await Task.Run(() =>
{
result = InnerFmt.ToUri(lstSelected);
});
if (!result.IsNullOrEmpty())
{
await _updateView?.Invoke(EViewAction.SetClipboardData, result);
NoticeManager.Instance.SendMessage(ResUI.BatchExportURLSuccessfully);
}
else
{
NoticeManager.Instance.Enqueue(ResUI.OperationFailed);
}
}
#endregion Add Servers
#region Subscription
@@ -192,6 +192,7 @@
Header="{x:Static resx:ResUI.menuExport2ShareUrl}"
InputGesture="Ctrl+C" />
<MenuItem x:Name="menuExport2ShareUrlBase64" Header="{x:Static resx:ResUI.menuExport2ShareUrlBase64}" />
<MenuItem x:Name="menuExport2InnerUri" Header="{x:Static resx:ResUI.menuExport2InnerUri}" />
</MenuItem>
<Separator />
<MenuItem Header="{x:Static resx:ResUI.menuGenGroupServer}">
@@ -88,6 +88,7 @@ public partial class ProfilesView : ReactiveUserControl<ProfilesViewModel>
this.BindCommand(ViewModel, vm => vm.Export2ClientConfigClipboardCmd, v => v.menuExport2ClientConfigClipboard).DisposeWith(disposables);
this.BindCommand(ViewModel, vm => vm.Export2ShareUrlCmd, v => v.menuExport2ShareUrl).DisposeWith(disposables);
this.BindCommand(ViewModel, vm => vm.Export2ShareUrlBase64Cmd, v => v.menuExport2ShareUrlBase64).DisposeWith(disposables);
this.BindCommand(ViewModel, vm => vm.Export2InnerUriCmd, v => v.menuExport2InnerUri).DisposeWith(disposables);
AppEvents.AppExitRequested
.AsObservable()
+4
View File
@@ -242,6 +242,10 @@
x:Name="menuExport2ShareUrlBase64"
Height="{StaticResource MenuItemHeight}"
Header="{x:Static resx:ResUI.menuExport2ShareUrlBase64}" />
<MenuItem
x:Name="menuExport2InnerUri"
Height="{StaticResource MenuItemHeight}"
Header="{x:Static resx:ResUI.menuExport2InnerUri}" />
</MenuItem>
<Separator />
<MenuItem Header="{x:Static resx:ResUI.menuGenGroupServer}">
+1
View File
@@ -82,6 +82,7 @@ public partial class ProfilesView
this.BindCommand(ViewModel, vm => vm.Export2ClientConfigClipboardCmd, v => v.menuExport2ClientConfigClipboard).DisposeWith(disposables);
this.BindCommand(ViewModel, vm => vm.Export2ShareUrlCmd, v => v.menuExport2ShareUrl).DisposeWith(disposables);
this.BindCommand(ViewModel, vm => vm.Export2ShareUrlBase64Cmd, v => v.menuExport2ShareUrlBase64).DisposeWith(disposables);
this.BindCommand(ViewModel, vm => vm.Export2InnerUriCmd, v => v.menuExport2InnerUri).DisposeWith(disposables);
AppEvents.AppExitRequested
.AsObservable()