Files
v2rayN/v2rayN/v2rayN.WebAPI/Services/V2rayRuntime.Profiles.cs
T
2dust 49963a1662 Extract database queries from AppManager
Move configuration queries to ConfigDbQuery and profile migrations to ConfigDbManager. Update desktop, Web API, and test callers to use the new locations.
2026-11-14 20:05:32 +08:00

566 lines
25 KiB
C#

using ServiceLib.Handler.Builder;
using ServiceLib.Handler.Fmt;
using ServiceLib.Services;
using v2rayN.WebAPI.Contracts;
namespace v2rayN.WebAPI.Services;
public sealed partial class V2rayRuntime
{
public async Task<IReadOnlyList<ProfileGroupView>> GetProfileGroupsAsync()
{
var subscriptions = await ConfigDbQuery.SubItems() ?? [];
var profiles = await ConfigDbQuery.ProfileItems(string.Empty) ?? [];
var counts = profiles.GroupBy(item => item.Subid).ToDictionary(group => group.Key, group => group.Count());
var groups = new List<ProfileGroupView>
{
new(string.Empty, "all", null, ApiMessageKeys.ProfilesAllGroup, profiles.Count, string.IsNullOrEmpty(Config.SubIndexId)),
};
groups.AddRange(subscriptions.Select(item => new ProfileGroupView(
item.Id,
"subscription",
item.Remarks,
null,
counts.GetValueOrDefault(item.Id),
item.Id == Config.SubIndexId)));
return groups;
}
public async Task<OperationView> SelectProfileGroupAsync(string? subscriptionId)
{
if (!string.IsNullOrWhiteSpace(subscriptionId)
&& await ConfigDbQuery.GetSubItem(subscriptionId) is null)
{
return OperationView.Fail("subscription_not_found", ApiMessageKeys.SubscriptionNotFound);
}
await _mutations.RunAsync(async () =>
{
Config.SubIndexId = subscriptionId ?? string.Empty;
await EnsureConfigSaveSucceededAsync(() => ConfigTextManager.Instance.SaveConfig());
});
_events.Publish("profiles-changed", new { subscriptionId = Config.SubIndexId });
return OperationView.Ok(ApiMessageKeys.CommonCompleted, new { subscriptionId = Config.SubIndexId });
}
public async Task<OperationView> ImportProfilesAsync(ProfileImportRequest request)
{
if (string.IsNullOrWhiteSpace(request.Content))
{
return OperationView.Fail("profile_content_required", ApiMessageKeys.CommonInvalidInput, new { imported = 0 });
}
var groupId = request.SubscriptionId ?? Config.SubIndexId ?? string.Empty;
if (!string.IsNullOrEmpty(groupId) && await ConfigDbQuery.GetSubItem(groupId) is null)
{
return OperationView.Fail("subscription_not_found", ApiMessageKeys.SubscriptionNotFound, new { imported = 0 });
}
var count = await _mutations.RunAsync(async () =>
{
var imported = await ConfigDbManager.AddBatchServers(Config, request.Content, groupId, request.IsSubscription);
if (imported > 0)
{
await EnsureConfigSaveSucceededAsync(() => ConfigTextManager.Instance.SaveConfig());
}
return imported;
});
if (count <= 0)
{
return OperationView.Fail("profile_import_empty", ApiMessageKeys.ProfileInvalid, new { imported = Math.Max(count, 0) });
}
_events.Publish("profiles-changed", new { subscriptionId = groupId });
AddLog("profile", $"Imported {count} profile(s).");
return OperationView.Ok(ApiMessageKeys.ProfileImported, new { imported = count });
}
public async Task<OperationView> SaveProfileAsync(ProfileItem profile, string? profileId = null)
{
if (profile is null)
{
return OperationView.Fail("profile_data_required", ApiMessageKeys.CommonInvalidInput);
}
var isNew = string.IsNullOrWhiteSpace(profileId);
if (!isNew)
{
var existing = await ConfigDbQuery.GetProfileItem(profileId!);
if (existing is null)
{
return OperationView.Fail("profile_not_found", ApiMessageKeys.ProfileNotFound);
}
if (!IsConfigTypeUnchanged(profile, existing))
{
return OperationView.Fail("profile_config_type_immutable", ApiMessageKeys.ProfileInvalid);
}
profile.IndexId = profileId!;
profile.Subid = existing.Subid;
profile.IsSub = existing.IsSub;
}
else
{
profile.IndexId = string.Empty;
profile.Subid = Config.SubIndexId ?? string.Empty;
profile.IsSub = false;
if (!string.IsNullOrEmpty(profile.Subid)
&& await ConfigDbQuery.GetSubItem(profile.Subid) is null)
{
AddLog("profile", $"Rejected profile creation for missing subscription group {profile.Subid}.");
return OperationView.Fail("subscription_not_found", ApiMessageKeys.SubscriptionNotFound,
new { subscriptionId = profile.Subid });
}
}
if (profile.ConfigType.IsGroupType())
{
profile.CoreType ??= ECoreType.Xray;
var protocolExtra = profile.GetProtocolExtra();
var childIds = Utils.String2List(protocolExtra.ChildItems) ?? [];
if (string.IsNullOrWhiteSpace(profile.Remarks)
|| (childIds.Count == 0 && string.IsNullOrWhiteSpace(protocolExtra.SubChildItems))
|| profile.CoreType is not (ECoreType.Xray or ECoreType.sing_box))
{
return OperationView.Fail("profile_group_invalid", ApiMessageKeys.ProfileInvalid);
}
if (!string.IsNullOrWhiteSpace(protocolExtra.SubChildItems)
&& await ConfigDbQuery.GetSubItem(protocolExtra.SubChildItems) is null)
{
return OperationView.Fail("subscription_not_found", ApiMessageKeys.SubscriptionNotFound);
}
var children = await ConfigDbQuery.GetProfileItemsByIndexIds(childIds);
if (children.Count != childIds.Distinct(StringComparer.Ordinal).Count()
|| await GroupProfileManager.HasCycle(profile))
{
return OperationView.Fail("profile_group_children_invalid", ApiMessageKeys.ProfileInvalid);
}
}
else if (profile.ConfigType is EConfigType.Custom or EConfigType.Outbound)
{
if (string.IsNullOrWhiteSpace(profile.Remarks) || string.IsNullOrWhiteSpace(profile.Address))
{
return OperationView.Fail("profile_validation_failed", ApiMessageKeys.ProfileInvalid);
}
}
else if (profile.ConfigType.IsComplexType())
{
return OperationView.Fail("profile_complex_type_requires_import", ApiMessageKeys.CommonInvalidInput);
}
else if (!TryValidateProfile(profile, out var code, out var messageKey))
{
return OperationView.Fail(code, messageKey);
}
var wasRunning = !isNew
&& profile.IndexId == CurrentCoreRuntime.ProfileId
&& CurrentCoreRuntime.State == CoreRuntimeState.Running;
int result;
try
{
result = await _mutations.RunAsync(() => profile.ConfigType.IsGroupType()
? ConfigDbManager.AddServerCommon(Config, profile)
: profile.ConfigType switch
{
EConfigType.Custom when isNew => ConfigDbManager.AddCustomServer(Config, profile, blDelete: false),
EConfigType.Outbound when isNew => ConfigDbManager.AddCustomOutboundServer(Config, profile, blDelete: false, toFile: true),
EConfigType.Custom or EConfigType.Outbound => ConfigDbManager.EditCustomServer(Config, profile),
_ => ConfigDbManager.AddServer(Config, profile),
});
}
catch (Exception exception)
{
AddLog("profile", $"Profile save failed for {profile.ConfigType} ({profile.IndexId}): {exception}");
return OperationView.Fail("profile_persistence_failed", ApiMessageKeys.ProfileSaveFailed,
new { stage = "servicelib_or_sqlite_write", exceptionType = exception.GetType().Name, detail = exception.Message });
}
if (result != 0)
{
AddLog("profile", $"ServiceLib rejected profile save for {profile.ConfigType} ({profile.IndexId}); result={result}.");
return OperationView.Fail("profile_save_rejected", ApiMessageKeys.ProfileSaveFailed,
new { stage = "servicelib_validation_or_persistence", result });
}
if (wasRunning)
{
var restart = await RestartCoreAsync(CancellationToken.None);
_events.Publish("profiles-changed", new { subscriptionId = Config.SubIndexId });
return restart.Success
? OperationView.Ok(ApiMessageKeys.ProfileSaved, new { profileId = profile.IndexId, coreRestarted = true })
: OperationView.Fail("profile_saved_core_restart_failed", ApiMessageKeys.ProfileSaved,
new { profileId = profile.IndexId, coreRestartRequired = true, coreResultCode = restart.Code });
}
_events.Publish("profiles-changed", new { subscriptionId = Config.SubIndexId });
return OperationView.Ok(ApiMessageKeys.ProfileSaved, new { profileId = profile.IndexId, coreRestarted = false });
}
internal static bool TryValidateProfile(ProfileItem profile, out string code, out string messageKey)
{
if (string.IsNullOrEmpty(profile.Remarks)
|| string.IsNullOrEmpty(profile.Address)
|| profile.Port <= 0
|| profile.Port >= Global.MaxPort
|| (profile.ConfigType is not EConfigType.SOCKS and not EConfigType.HTTP
&& string.IsNullOrEmpty(profile.Password)))
{
code = "profile_validation_failed";
messageKey = ApiMessageKeys.ProfileInvalid;
return false;
}
var protocolExtra = profile.GetProtocolExtra();
if (profile.ConfigType == EConfigType.Shadowsocks && string.IsNullOrEmpty(protocolExtra.SsMethod))
{
code = "profile_validation_failed";
messageKey = ApiMessageKeys.ProfileInvalid;
return false;
}
if (profile.ConfigType == EConfigType.HTTP
&& !string.IsNullOrEmpty(protocolExtra.HttpHeaders)
&& JsonUtils.ParseJson(protocolExtra.HttpHeaders) is null)
{
code = "profile_http_headers_invalid";
messageKey = ApiMessageKeys.ProfileInvalid;
return false;
}
code = "ok";
messageKey = string.Empty;
return true;
}
internal static bool IsConfigTypeUnchanged(ProfileItem input, ProfileItem existing) =>
input.ConfigType == existing.ConfigType;
public async Task<OperationView> DeleteProfilesAsync(IEnumerable<string> profileIds)
{
var ids = profileIds.Where(id => !string.IsNullOrWhiteSpace(id)).Distinct(StringComparer.Ordinal).ToArray();
if (ids.Length == 0)
{
return OperationView.Fail("profile_selection_required", ApiMessageKeys.ProfileSelectRequired);
}
var selected = await ConfigDbQuery.GetProfileItemsByIndexIds(ids);
if (selected.Count == 0)
{
return OperationView.Fail("profile_not_found", ApiMessageKeys.ProfileNotFound);
}
var runningProfileId = CurrentCoreRuntime.ProfileId;
var removesCurrent = ids.Contains(runningProfileId ?? Config.IndexId, StringComparer.Ordinal);
var wasRunning = CurrentCoreRuntime.State == CoreRuntimeState.Running;
if (removesCurrent && wasRunning)
{
var stop = await StopCoreAsync(CancellationToken.None);
if (!stop.Success)
{
return stop;
}
}
await _mutations.RunAsync(async () =>
{
if (await ConfigDbManager.RemoveServers(Config, selected) != 0)
{
throw new IOException("ServiceLib could not remove the selected profiles.");
}
if (removesCurrent)
{
_ = await ConfigDbManager.GetDefaultServer(Config);
await EnsureConfigSaveSucceededAsync(() => ConfigTextManager.Instance.SaveConfig());
}
});
if (removesCurrent && wasRunning && !string.IsNullOrEmpty(Config.IndexId))
{
var restart = await StartCoreAsync(null, CancellationToken.None);
_events.Publish("profiles-changed", new { subscriptionId = Config.SubIndexId });
return restart.Success
? OperationView.Ok(ApiMessageKeys.ProfileDeleted, new { deleted = selected.Count, coreRestarted = true })
: OperationView.Fail("profiles_deleted_core_restart_failed", ApiMessageKeys.ProfileDeleted,
new { deleted = selected.Count, coreRestartRequired = true, coreResultCode = restart.Code });
}
_events.Publish("profiles-changed", new { subscriptionId = Config.SubIndexId });
return OperationView.Ok(ApiMessageKeys.ProfileDeleted, new { deleted = selected.Count });
}
public async Task<OperationView> CopyProfilesAsync(IEnumerable<string> profileIds)
{
var ids = profileIds.Where(id => !string.IsNullOrWhiteSpace(id)).Distinct(StringComparer.Ordinal).ToArray();
var items = await ConfigDbQuery.GetProfileItemsOrderedByIndexIds(ids);
if (items.Count == 0)
{
return OperationView.Fail("profile_not_found", ApiMessageKeys.ProfileNotFound);
}
if (await _mutations.RunAsync(() => ConfigDbManager.CopyServer(Config, items)) != 0)
{
return OperationView.Fail("profile_copy_failed", ApiMessageKeys.CommonInvalidInput);
}
_events.Publish("profiles-changed", new { subscriptionId = Config.SubIndexId });
return OperationView.Ok(ApiMessageKeys.ProfileCopied, new { copied = items.Count });
}
public async Task<OperationView> MoveProfilesToGroupAsync(MoveProfilesRequest request)
{
if (!string.IsNullOrEmpty(request.SubscriptionId)
&& await ConfigDbQuery.GetSubItem(request.SubscriptionId) is null)
{
return OperationView.Fail("subscription_not_found", ApiMessageKeys.SubscriptionNotFound);
}
var items = await ConfigDbQuery.GetProfileItemsByIndexIds(request.ProfileIds);
if (items.Count == 0)
{
return OperationView.Fail("profile_not_found", ApiMessageKeys.ProfileNotFound);
}
if (await _mutations.RunAsync(() => ConfigDbManager.MoveToGroup(Config, items, request.SubscriptionId ?? string.Empty)) != 0)
{
return OperationView.Fail("profile_move_failed", ApiMessageKeys.CommonInvalidInput);
}
_events.Publish("profiles-changed", new { subscriptionId = Config.SubIndexId });
return OperationView.Ok(ApiMessageKeys.ProfileMoved, new { moved = items.Count, subscriptionId = request.SubscriptionId });
}
public async Task<OperationView> MoveProfileAsync(MoveProfileRequest request)
{
var groupId = Config.SubIndexId ?? string.Empty;
var profiles = await GetProfilesAsync(groupId, null);
var orderedIds = profiles.Select(item => item.IndexId).ToList();
var index = orderedIds.IndexOf(request.ProfileId);
if (index < 0)
{
return OperationView.Fail("profile_not_in_group", ApiMessageKeys.ProfileNotInGroup);
}
var result = await _mutations.RunAsync(() => ConfigDbManager.MoveServer(Config, orderedIds, index, request.Direction, request.Position));
_events.Publish("profiles-changed", new { subscriptionId = groupId });
return result == 0
? OperationView.Ok(ApiMessageKeys.ProfileOrderSaved)
: OperationView.Fail("profile_order_failed", ApiMessageKeys.CommonInvalidInput);
}
public async Task<OperationView> SortProfilesAsync(SortProfilesRequest request)
{
var groupId = request.SubscriptionId ?? Config.SubIndexId ?? string.Empty;
if (!Enum.TryParse<EServerColName>(request.Column, true, out _))
{
return OperationView.Fail("profile_sort_column_invalid", ApiMessageKeys.CommonInvalidInput);
}
var result = await _mutations.RunAsync(() => ConfigDbManager.SortServers(Config, groupId, request.Column, request.Ascending));
_events.Publish("profiles-changed", new { subscriptionId = groupId });
return result == 0
? OperationView.Ok(ApiMessageKeys.ProfileOrderSaved)
: OperationView.Fail("profile_sort_failed", ApiMessageKeys.CommonInvalidInput);
}
public async Task<OperationView> RemoveDuplicateProfilesAsync(string? subscriptionId)
{
var result = await _mutations.RunAsync(() => ConfigDbManager.DedupServerList(Config, subscriptionId ?? Config.SubIndexId ?? string.Empty));
_events.Publish("profiles-changed", new { subscriptionId = subscriptionId ?? Config.SubIndexId });
return OperationView.Ok(ApiMessageKeys.ProfileDeduplicated, new { removed = result.Item1 - result.Item2 });
}
public async Task<OperationView> RemoveInvalidProfilesAsync(string? subscriptionId)
{
var count = await _mutations.RunAsync(() => ConfigDbManager.RemoveInvalidServerResult(Config, subscriptionId ?? Config.SubIndexId ?? string.Empty));
_events.Publish("profiles-changed", new { subscriptionId = subscriptionId ?? Config.SubIndexId });
return count < 0
? OperationView.Ok(ApiMessageKeys.CommonCompleted, new { removed = 0 })
: OperationView.Ok(ApiMessageKeys.ProfileInvalidRemoved, new { removed = count });
}
public async Task<OperationView> GenerateProfileGroupsAsync(string? subscriptionId, bool byRegion)
{
SubItem? subscription = null;
if (!string.IsNullOrEmpty(subscriptionId))
{
subscription = await ConfigDbQuery.GetSubItem(subscriptionId);
if (subscription is null)
{
return OperationView.Fail("subscription_not_found", ApiMessageKeys.SubscriptionNotFound);
}
}
var result = await _mutations.RunAsync(() => byRegion
? ConfigDbManager.AddGroupRegionServer(Config, subscription)
: ConfigDbManager.AddGroupAllServer(Config, subscription));
if (!result.Success)
{
return OperationView.Fail("profile_group_empty", ApiMessageKeys.ProfileGrouped, result.Data);
}
_events.Publish("profiles-changed", new { subscriptionId = subscription?.Id ?? string.Empty });
return OperationView.Ok(ApiMessageKeys.ProfileGrouped, result.Data);
}
public async Task<IReadOnlyList<ProfileExportItem>> ExportProfileDataAsync(ProfileExportRequest request)
{
var items = await ConfigDbQuery.GetProfileItemsOrderedByIndexIds(request.ProfileIds);
var output = new List<ProfileExportItem>();
if (request.IncludeShareUris)
{
var uris = items.Select(FmtHandler.GetShareUri).Where(uri => !string.IsNullOrWhiteSpace(uri)).ToArray();
if (uris.Length > 0)
{
var content = string.Join(Environment.NewLine, uris) + Environment.NewLine;
output.Add(new(string.Empty, string.Empty, request.Base64ShareUris ? "share-uri-base64" : "share-uri",
request.Base64ShareUris ? Utils.Base64Encode(content) : content));
}
}
if (request.IncludeInnerUri && items.Count > 0)
{
var innerUri = InnerFmt.ToUri(items);
if (!string.IsNullOrWhiteSpace(innerUri))
{
output.Add(new(string.Empty, string.Empty, "inner-uri", innerUri));
}
}
if (request.IncludeClientConfig)
{
foreach (var item in items)
{
var (context, validation) = await CoreConfigContextBuilder.Build(Config, item);
if (validation.Success)
{
var generated = await CoreConfigHandler.GenerateClientConfig(context, null);
if (generated.Success && generated.Data is string configText)
{
output.Add(new(item.IndexId, item.Remarks, "client-config", configText));
}
}
}
}
return output;
}
public async Task<string?> GetSubscriptionShareAsync(string id)
{
return (await ConfigDbQuery.GetSubItem(id))?.Url;
}
public async Task<OperationView> StartSpeedTestAsync(SpeedTestRequest request, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
var action = request.Action == ESpeedActionType.FastRealping ? ESpeedActionType.Realping : request.Action;
var profiles = request.ProfileIds is { Length: > 0 }
? await ConfigDbQuery.GetProfileItemsOrderedByIndexIds(request.ProfileIds)
: await ConfigDbQuery.ProfileItems(Config.SubIndexId ?? string.Empty) ?? [];
if (profiles.Count == 0)
{
return OperationView.Fail("speedtest_profile_selection_required", ApiMessageKeys.SpeedTestSelectRequired);
}
if (action != ESpeedActionType.Tcping
&& profiles.Where(item => !item.ConfigType.IsComplexType())
.Any(item => AppManager.Instance.GetCoreType(item, item.ConfigType) is not (ECoreType.Xray or ECoreType.sing_box)))
{
return OperationView.Fail("speedtest_core_unsupported", ApiMessageKeys.SpeedTestUnsupportedCore);
}
lock (_speedtestGate)
{
if (_speedtestTask is { IsCompleted: false })
{
return OperationView.Fail("speedtest_busy", ApiMessageKeys.SpeedTestBusy);
}
var speedtestService = _speedtestService ??= CreateSpeedtestService();
var speedtestCancellation = new CancellationTokenSource();
_speedtestCancellation = speedtestCancellation;
_speedtestTask = Task.Run(async () =>
{
try
{
using var requested = CancellationTokenSource.CreateLinkedTokenSource(_operations.ShutdownToken, speedtestCancellation.Token);
await using var operation = await _operations.EnterOperationAsync(requested.Token);
operation.Token.ThrowIfCancellationRequested();
await speedtestService.RunLoop(action, profiles, operation.Token);
}
catch (OperationCanceledException) when (_operations.IsStopping)
{
// Expected during graceful service shutdown.
}
catch (OperationCanceledException) when (speedtestCancellation.IsCancellationRequested)
{
// Expected when the caller stops the active speed test.
}
catch (Exception ex)
{
AddLog("speedtest", ex.Message);
}
finally
{
lock (_speedtestGate)
{
if (ReferenceEquals(_speedtestCancellation, speedtestCancellation))
{
_speedtestCancellation = null;
}
}
speedtestCancellation.Dispose();
}
});
}
_events.Publish("speedtest-started", new
{
code = "speedtest_started",
messageKey = ApiMessageKeys.SpeedTestStarted,
action = System.Text.Json.JsonNamingPolicy.CamelCase.ConvertName(action.ToString()),
profileIds = profiles.Select(item => item.IndexId),
});
return OperationView.Ok(ApiMessageKeys.SpeedTestStarted, new { action = action.ToString(), profileCount = profiles.Count });
}
private SpeedtestService CreateSpeedtestService() => new(Config, result =>
{
int? delay = int.TryParse(result.Delay, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var parsedDelay)
? parsedDelay
: null;
decimal? speed = decimal.TryParse(result.Speed, System.Globalization.NumberStyles.Number, System.Globalization.CultureInfo.InvariantCulture, out var parsedSpeed)
? parsedSpeed
: null;
_events.Publish("speedtest-result", new
{
code = ApiMessageKeys.SpeedTestResult,
messageKey = ApiMessageKeys.SpeedTestResult,
indexId = result.IndexId,
delay,
speed,
ipInfo = result.IpInfo,
rawResult = delay is null && speed is null ? (result.Delay ?? result.Speed) : null,
});
if (!string.IsNullOrEmpty(result.IndexId))
{
AddLog("speedtest", $"{result.IndexId}: delay={result.Delay}, speed={result.Speed}");
}
return Task.CompletedTask;
});
public OperationView StopSpeedTests()
{
lock (_speedtestGate)
{
_speedtestCancellation?.Cancel();
_speedtestService?.ExitLoop();
}
return OperationView.Ok(ApiMessageKeys.CommonCompleted);
}
public IReadOnlyList<string> GetRunningSubscriptionUpdates()
{
lock (_subscriptionGate)
{
return _subscriptionTasks.Where(pair => !pair.Value.IsCompleted).Select(pair => pair.Key).ToArray();
}
}
}