/*
* Copyright (c) 2025 Proton AG
*
* This file is part of ProtonVPN.
*
* ProtonVPN is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* ProtonVPN is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with ProtonVPN. If not, see .
*/
using System.Collections.Concurrent;
using System.Net;
using System.Text.RegularExpressions;
using ProtonVPN.Api.Contracts;
using ProtonVPN.Api.Contracts.Servers;
using ProtonVPN.Client.EventMessaging.Contracts;
using ProtonVPN.Client.Logic.Servers.Cache;
using ProtonVPN.Client.Logic.Servers.Contracts;
using ProtonVPN.Client.Logic.Servers.Contracts.Messages;
using ProtonVPN.Client.Logic.Servers.Contracts.Searches;
using ProtonVPN.Common.Core.Extensions;
using ProtonVPN.Common.Core.Helpers;
using ProtonVPN.Configurations.Contracts;
namespace ProtonVPN.Client.Logic.Servers;
public partial class ServerFinder : IServerFinder
{
private static readonly TimeSpan _searchResultExpirationInterval = TimeSpan.FromMinutes(15);
private static readonly TimeSpan _cleanupInterval = TimeSpan.FromMinutes(5);
private readonly IConfiguration _config;
private readonly IServersCache _serversCache;
private readonly IEventMessageSender _eventMessageSender;
private readonly IFavoriteServersStorage _favoriteServersStorage;
private readonly Lazy> _serverSearchDebouncer;
[GeneratedRegex("[a-zA-Z]{2,}(-[a-zA-Z]{2,})*(#)?[0-9]+(-[a-zA-Z0-9]{2,})*")]
private static partial Regex ServerNameRegex();
private readonly Regex _serverNameRegex = ServerNameRegex();
private readonly ConcurrentDictionary _searchCache = new(StringComparer.InvariantCultureIgnoreCase);
private Timer? _cleanupTimer;
private readonly object _timerLock = new();
private readonly object _searchBlockLock = new();
private bool _isSearchBlocked;
public ServerFinder(
IConfiguration config,
IServersCache serversCache,
IEventMessageSender eventMessageSender,
IFavoriteServersStorage favoriteServersStorage)
{
_config = config;
_serversCache = serversCache;
_eventMessageSender = eventMessageSender;
_favoriteServersStorage = favoriteServersStorage;
_serverSearchDebouncer = new(() => new(_config.ServerSearchDelay, input => TriggerServerSearchAsync(input)));
}
public void Search(string input)
{
lock (_timerLock)
{
_cleanupTimer ??= new Timer(CleanupSearchCache, null, _cleanupInterval, _cleanupInterval);
}
lock (_searchBlockLock)
{
bool isSearchBlocked = _isSearchBlocked;
if (!isSearchBlocked && _serverNameRegex.IsMatch(input) && !IsCacheContaining(input))
{
input = AddMissingHashSymbolIfNeeded(input);
_serverSearchDebouncer.Value.Call(input);
}
}
}
private bool IsCacheContaining(string input)
{
DateTime utcNow = DateTime.UtcNow;
return _searchCache.TryGetValue(input, out DateTime expirationTime) &&
expirationTime >= utcNow;
}
private string AddMissingHashSymbolIfNeeded(string input)
{
if (input.Contains('#'))
{
return input;
}
int indexOfFirstDigit = input.IndexOfFirstDigit();
if (indexOfFirstDigit < 0)
{
return input;
}
return input.Insert(indexOfFirstDigit, "#");
}
private async Task TriggerServerSearchAsync(string input)
{
bool isSearchBlocked;
lock (_searchBlockLock)
{
isSearchBlocked = _isSearchBlocked;
}
if (isSearchBlocked)
{
return;
}
ApiResponseResult? response = await _serversCache.LookupAsync(input);
if (response is null)
{
return;
}
if (response.ResponseMessage.StatusCode == HttpStatusCode.TooManyRequests)
{
lock (_searchBlockLock)
{
_isSearchBlocked = true;
Cancel();
}
}
if (response.Success || response.Value.Code == ResponseCodes.SERVER_DOES_NOT_EXIST)
{
_searchCache.TryAdd(input, DateTime.UtcNow + _searchResultExpirationInterval);
}
if (response.Success)
{
_favoriteServersStorage.SetLastSearchedServerId(response.Value.LogicalServer.Id);
_eventMessageSender.Send();
}
}
public void Cancel()
{
_serverSearchDebouncer.Value.Cancel();
}
public void ClearSearchBlock()
{
lock (_searchBlockLock)
{
_isSearchBlocked = false;
}
}
private void CleanupSearchCache(object? state)
{
DateTime utcNow = DateTime.UtcNow;
List expiredKeys = _searchCache
.Where(kvp => kvp.Value <= utcNow)
.Select(kvp => kvp.Key)
.ToList();
foreach (string key in expiredKeys)
{
_searchCache.TryRemove(key, out _);
}
if (_searchCache.IsEmpty)
{
lock (_timerLock)
{
_cleanupTimer?.Dispose();
_cleanupTimer = null;
}
}
}
}