diff --git a/src/BirdsiteLive.Common/Regexes/UrlRegexes.cs b/src/BirdsiteLive.Common/Regexes/UrlRegexes.cs index 1f2b279..cb93838 100644 --- a/src/BirdsiteLive.Common/Regexes/UrlRegexes.cs +++ b/src/BirdsiteLive.Common/Regexes/UrlRegexes.cs @@ -5,5 +5,7 @@ namespace BirdsiteLive.Common.Regexes public class UrlRegexes { public static readonly Regex Url = new Regex(@"(.?)(((http|ftp|https):\/\/)[\w\-_]+(\.[\w\-_]+)+([\w\-\.,@?^=%&:/~\+#]*[\w\-\@?^=%&/~\+#])?)"); + + public static readonly Regex Domain = new Regex(@"^[a-zA-Z0-9\-_]+(\.[a-zA-Z0-9\-_]+)+$"); } } \ No newline at end of file diff --git a/src/BirdsiteLive.Domain/MigrationService.cs b/src/BirdsiteLive.Domain/MigrationService.cs index 975272e..e0dfdb5 100644 --- a/src/BirdsiteLive.Domain/MigrationService.cs +++ b/src/BirdsiteLive.Domain/MigrationService.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Linq; using BirdsiteLive.Twitter; using System.Security.Cryptography; @@ -11,25 +12,34 @@ using BirdsiteLive.ActivityPub.Converters; using BirdsiteLive.Common.Settings; using BirdsiteLive.DAL.Models; using BirdsiteLive.Domain.Enum; +using System.Net.Http; +using BirdsiteLive.Common.Regexes; +using Microsoft.Extensions.Logging; namespace BirdsiteLive.Domain { public class MigrationService { private readonly InstanceSettings _instanceSettings; + private readonly ITheFedInfoService _theFedInfoService; private readonly ITwitterTweetsService _twitterTweetsService; private readonly IActivityPubService _activityPubService; private readonly ITwitterUserDal _twitterUserDal; private readonly IFollowersDal _followersDal; + private readonly IHttpClientFactory _httpClientFactory; + private readonly ILogger _logger; #region Ctor - public MigrationService(ITwitterTweetsService twitterTweetsService, IActivityPubService activityPubService, ITwitterUserDal twitterUserDal, IFollowersDal followersDal, InstanceSettings instanceSettings) + public MigrationService(ITwitterTweetsService twitterTweetsService, IActivityPubService activityPubService, ITwitterUserDal twitterUserDal, IFollowersDal followersDal, InstanceSettings instanceSettings, ITheFedInfoService theFedInfoService, IHttpClientFactory httpClientFactory, ILogger logger) { _twitterTweetsService = twitterTweetsService; _activityPubService = activityPubService; _twitterUserDal = twitterUserDal; _followersDal = followersDal; _instanceSettings = instanceSettings; + _theFedInfoService = theFedInfoService; + _httpClientFactory = httpClientFactory; + _logger = logger; } #endregion @@ -63,7 +73,7 @@ namespace BirdsiteLive.Domain if (tweet.CreatorName.Trim().ToLowerInvariant() != acct.Trim().ToLowerInvariant()) throw new Exception($"Tweet not published by @{acct}"); - + if (!tweet.MessageContent.Contains(code)) { var message = "Tweet don't have migration code"; @@ -180,7 +190,7 @@ namespace BirdsiteLive.Domain } catch (Exception e) { - Console.WriteLine(e); + _logger.LogError(e, e.Message); } } }); @@ -225,7 +235,14 @@ namespace BirdsiteLive.Domain var t1 = Task.Run(async () => { - await _activityPubService.DeleteUserAsync(acct, host, sharedInbox); + try + { + await _activityPubService.DeleteUserAsync(acct, host, sharedInbox); + } + catch (Exception e) + { + _logger.LogError(e, e.Message); + } }); } @@ -239,20 +256,74 @@ namespace BirdsiteLive.Domain var t1 = Task.Run(async () => { - await _activityPubService.DeleteUserAsync(acct, host, sharedInbox); + try + { + await _activityPubService.DeleteUserAsync(acct, host, sharedInbox); + } + catch (Exception e) + { + _logger.LogError(e, e.Message); + } }); } }); } - public async Task TriggerRemoteMigrationAsync(string id, string tweetid, string handle) + public async Task TriggerRemoteMigrationAsync(string id, string tweetIdStg, string handle) { - //TODO + var url = $"https://{{0}}/migration/move/{{1}}/{{2}}/{handle}"; + await ProcessRemoteMigrationAsync(id, tweetIdStg, url); + } - public async Task TriggerRemoteDeleteAsync(string id, string tweetid) + public async Task TriggerRemoteDeleteAsync(string id, string tweetIdStg) { - //TODO + var url = $"https://{{0}}/migration/delete/{{1}}/{{2}}"; + await ProcessRemoteMigrationAsync(id, tweetIdStg, url); + } + + private async Task ProcessRemoteMigrationAsync(string id, string tweetIdStg, string urlPattern) + { + try + { + var instances = await RetrieveCompatibleBslInstancesAsync(); + var tweetId = ExtractedTweetId(tweetIdStg); + + foreach (var instance in instances) + { + try + { + var host = instance.Host; + if(!UrlRegexes.Domain.IsMatch(host)) continue; + + var url = string.Format(urlPattern, host, id, tweetId); + + var client = _httpClientFactory.CreateClient(); + var result = await client.PostAsync(url, null); + result.EnsureSuccessStatusCode(); + } + catch (Exception e) + { + _logger.LogError(e, e.Message); + } + } + } + catch (Exception e) + { + _logger.LogError(e, e.Message); + } + } + + private async Task> RetrieveCompatibleBslInstancesAsync() + { + var instances = await _theFedInfoService.GetBslInstanceListAsync(); + var filteredInstances = instances + .Where(x => x.Version >= new Version(0, 21, 0)) + .Where(x => string.Compare(x.Host, + _instanceSettings.Domain, + StringComparison.InvariantCultureIgnoreCase) != 0) + .ToList(); + return filteredInstances; } private byte[] GetHash(string inputString) diff --git a/src/BirdsiteLive.Domain/TheFedInfoService.cs b/src/BirdsiteLive.Domain/TheFedInfoService.cs new file mode 100644 index 0000000..e85d7fa --- /dev/null +++ b/src/BirdsiteLive.Domain/TheFedInfoService.cs @@ -0,0 +1,162 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Newtonsoft.Json; + +namespace BirdsiteLive.Domain +{ + public interface ITheFedInfoService + { + Task> GetBslInstanceListAsync(); + } + + public class TheFedInfoService : ITheFedInfoService + { + private readonly IHttpClientFactory _httpClientFactory; + + #region Ctor + public TheFedInfoService(IHttpClientFactory httpClientFactory) + { + _httpClientFactory = httpClientFactory; + } + #endregion + + public async Task> GetBslInstanceListAsync() + { + var cancellationToken = CancellationToken.None; + + var result = await CallGraphQlAsync( + new Uri("https://the-federation.info/graphql"), + HttpMethod.Get, + "query ($platform: String!) { nodes(platform: $platform) { host, version } }", + new + { + platform = "birdsitelive", + }, + cancellationToken); + + var convertedResults = ConvertResults(result); + return convertedResults; + } + + private List ConvertResults(GraphQLResponse qlData) + { + var results = new List(); + + foreach (var instanceInfo in qlData.Data.Nodes) + { + try + { + var rawVersion = instanceInfo.Version.Split('+').First(); + if (string.IsNullOrWhiteSpace(rawVersion)) continue; + var version = Version.Parse(rawVersion); + if(version <= new Version(0,1,0)) continue; + + var instance = new BslInstanceInfo + { + Host = instanceInfo.Host, + Version = version + }; + results.Add(instance); + } + catch (Exception e) + { + Console.WriteLine(e); + } + } + + return results; + } + + private async Task> CallGraphQlAsync(Uri endpoint, HttpMethod method, string query, object variables, CancellationToken cancellationToken) + { + var content = new StringContent(SerializeGraphQlCall(query, variables), Encoding.UTF8, "application/json"); + var httpRequestMessage = new HttpRequestMessage + { + Method = method, + Content = content, + RequestUri = endpoint, + }; + //add authorization headers if necessary here + httpRequestMessage.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + var httpClient = _httpClientFactory.CreateClient(); + using (var response = await httpClient.SendAsync(httpRequestMessage, cancellationToken)) + { + //if (response.IsSuccessStatusCode) + if (response?.Content.Headers.ContentType?.MediaType == "application/json") + { + var responseString = await response.Content.ReadAsStringAsync(); //cancellationToken supported for .NET 5/6 + return DeserializeGraphQlCall(responseString); + } + else + { + throw new ApplicationException($"Unable to contact '{endpoint}': {response.StatusCode} - {response.ReasonPhrase}"); + } + } + } + + private string SerializeGraphQlCall(string query, object variables) + { + var sb = new StringBuilder(); + var textWriter = new StringWriter(sb); + var serializer = new JsonSerializer(); + serializer.Serialize(textWriter, new + { + query = query, + variables = variables, + }); + return sb.ToString(); + } + + private GraphQLResponse DeserializeGraphQlCall(string response) + { + var serializer = new JsonSerializer(); + var stringReader = new StringReader(response); + var jsonReader = new JsonTextReader(stringReader); + var result = serializer.Deserialize>(jsonReader); + return result; + } + + private class GraphQLResponse + { + public List Errors { get; set; } + public TResponse Data { get; set; } + } + + private class GraphQLError + { + public string Message { get; set; } + public List Locations { get; set; } + public List Path { get; set; } //either int or string + } + + private class GraphQLErrorLocation + { + public int Line { get; set; } + public int Column { get; set; } + } + + private class MyResponseData + { + public Node[] Nodes { get; set; } + } + + private class Node + { + public string Host { get; set; } + public string Version { get; set; } + } + } + + public class BslInstanceInfo + { + public string Host { get; set; } + public Version Version { get; set; } + } +} \ No newline at end of file diff --git a/src/BirdsiteLive/Controllers/MigrationController.cs b/src/BirdsiteLive/Controllers/MigrationController.cs index 386644a..f2cde09 100644 --- a/src/BirdsiteLive/Controllers/MigrationController.cs +++ b/src/BirdsiteLive/Controllers/MigrationController.cs @@ -7,6 +7,8 @@ using Npgsql.TypeHandlers; using BirdsiteLive.Domain; using BirdsiteLive.Domain.Enum; using BirdsiteLive.DAL.Contracts; +using BirdsiteLive.Models; +using System.Reflection.Metadata; namespace BirdsiteLive.Controllers { @@ -76,8 +78,8 @@ namespace BirdsiteLive.Controllers data.ErrorMessage = "This account has been deleted, it can't be migrated"; return View("Index", data); } - if (twitterAccount != null && - (!string.IsNullOrWhiteSpace(twitterAccount.MovedTo) + if (twitterAccount != null && + (!string.IsNullOrWhiteSpace(twitterAccount.MovedTo) || !string.IsNullOrWhiteSpace(twitterAccount.MovedToAcct))) { data.ErrorMessage = "This account has been moved already, it can't be migrated again"; @@ -103,7 +105,7 @@ namespace BirdsiteLive.Controllers try { await _migrationService.MigrateAccountAsync(fediverseUserValidation, id); - await _migrationService.TriggerRemoteMigrationAsync(id, tweetid, handle); + _migrationService.TriggerRemoteMigrationAsync(id, tweetid, handle); data.MigrationSuccess = true; } catch (Exception e) @@ -131,7 +133,7 @@ namespace BirdsiteLive.Controllers TweetId = tweetid }; - + //Verify can be deleted var twitterAccount = await _twitterUserDal.GetTwitterUserAsync(id); if (twitterAccount != null && twitterAccount.Deleted) @@ -156,7 +158,7 @@ namespace BirdsiteLive.Controllers try { await _migrationService.DeleteAccountAsync(id); - await _migrationService.TriggerRemoteDeleteAsync(id, tweetid); + _migrationService.TriggerRemoteDeleteAsync(id, tweetid); data.MigrationSuccess = true; } catch (Exception e) @@ -173,11 +175,16 @@ namespace BirdsiteLive.Controllers [Route("/migration/move/{id}/{tweetid}/{handle}")] public async Task RemoteMigrateMove(string id, string tweetid, string handle) { + //Check inputs + if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(tweetid) || + string.IsNullOrWhiteSpace(handle)) + return StatusCode(422); + //Verify can be migrated var twitterAccount = await _twitterUserDal.GetTwitterUserAsync(id); - if (twitterAccount.Deleted + if (twitterAccount != null && (twitterAccount.Deleted || !string.IsNullOrWhiteSpace(twitterAccount.MovedTo) - || !string.IsNullOrWhiteSpace(twitterAccount.MovedToAcct)) + || !string.IsNullOrWhiteSpace(twitterAccount.MovedToAcct))) return Ok(); // Start migration @@ -197,9 +204,13 @@ namespace BirdsiteLive.Controllers [Route("/migration/delete/{id}/{tweetid}")] public async Task RemoteMigrateDelete(string id, string tweetid) { + //Check inputs + if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(tweetid)) + return StatusCode(422); + //Verify can be deleted var twitterAccount = await _twitterUserDal.GetTwitterUserAsync(id); - if (twitterAccount.Deleted) return Ok(); + if (twitterAccount != null && twitterAccount.Deleted) return Ok(); // Start deletion var isTweetValid = _migrationService.ValidateTweet(id, tweetid, MigrationTypeEnum.Deletion); @@ -213,25 +224,4 @@ namespace BirdsiteLive.Controllers return StatusCode(400); } } - - - - public class MigrationData - { - public string Acct { get; set; } - - public string FediverseAccount { get; set; } - public string TweetId { get; set; } - - public string MigrationCode { get; set; } - - public bool IsTweetProvided { get; set; } - public bool IsAcctProvided { get; set; } - - public bool IsTweetValid { get; set; } - public bool IsAcctValid { get; set; } - - public string ErrorMessage { get; set; } - public bool MigrationSuccess { get; set; } - } } diff --git a/src/BirdsiteLive/Models/MigrationData.cs b/src/BirdsiteLive/Models/MigrationData.cs new file mode 100644 index 0000000..77185f9 --- /dev/null +++ b/src/BirdsiteLive/Models/MigrationData.cs @@ -0,0 +1,21 @@ +namespace BirdsiteLive.Models +{ + public class MigrationData + { + public string Acct { get; set; } + + public string FediverseAccount { get; set; } + public string TweetId { get; set; } + + public string MigrationCode { get; set; } + + public bool IsTweetProvided { get; set; } + public bool IsAcctProvided { get; set; } + + public bool IsTweetValid { get; set; } + public bool IsAcctValid { get; set; } + + public string ErrorMessage { get; set; } + public bool MigrationSuccess { get; set; } + } +} \ No newline at end of file diff --git a/src/BirdsiteLive/Views/Migration/Delete.cshtml b/src/BirdsiteLive/Views/Migration/Delete.cshtml index 9df9f62..568b08a 100644 --- a/src/BirdsiteLive/Views/Migration/Delete.cshtml +++ b/src/BirdsiteLive/Views/Migration/Delete.cshtml @@ -1,4 +1,4 @@ -@model BirdsiteLive.Controllers.MigrationData +@model MigrationData @{ ViewData["Title"] = "Migration"; } diff --git a/src/BirdsiteLive/Views/Migration/Index.cshtml b/src/BirdsiteLive/Views/Migration/Index.cshtml index 670a209..2d1db53 100644 --- a/src/BirdsiteLive/Views/Migration/Index.cshtml +++ b/src/BirdsiteLive/Views/Migration/Index.cshtml @@ -1,4 +1,4 @@ -@model BirdsiteLive.Controllers.MigrationData +@model MigrationData @{ ViewData["Title"] = "Migration"; } diff --git a/src/Tests/BirdsiteLive.Common.Tests/Regexes/UrlRegexesTests.cs b/src/Tests/BirdsiteLive.Common.Tests/Regexes/UrlRegexesTests.cs new file mode 100644 index 0000000..78a06be --- /dev/null +++ b/src/Tests/BirdsiteLive.Common.Tests/Regexes/UrlRegexesTests.cs @@ -0,0 +1,72 @@ +using BirdsiteLive.Common.Regexes; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace BirdsiteLive.Common.Tests +{ + [TestClass] + public class UrlRegexesTests + { + [TestMethod] + public void Url_Test() + { + var input = "https://misskey.tdl/users/8hwf6zy2k1#main-key"; + Assert.IsTrue(UrlRegexes.Url.IsMatch(input)); + } + + [TestMethod] + public void Url_Not_Test() + { + var input = "misskey.tdl/users/8hwf6zy2k1#main-key"; + Assert.IsFalse(UrlRegexes.Url.IsMatch(input)); + } + + [TestMethod] + public void Domain_Test() + { + var input = "misskey-data_sq.tdl"; + Assert.IsTrue(UrlRegexes.Domain.IsMatch(input)); + } + + [TestMethod] + public void Domain_Numbers_Test() + { + var input = "miss45654QAzedqskey-data_sq.tdl"; + Assert.IsTrue(UrlRegexes.Domain.IsMatch(input)); + } + + [TestMethod] + public void Domain_Subdomain_Test() + { + var input = "s.sub.dqdq-_Dz9sd.tdl"; + Assert.IsTrue(UrlRegexes.Domain.IsMatch(input)); + } + + [TestMethod] + public void Domain_Not_Test() + { + var input = "mis$s45654QAzedqskey-data_sq.tdl"; + Assert.IsFalse(UrlRegexes.Domain.IsMatch(input)); + } + + [TestMethod] + public void Domain_Slash_Test() + { + var input = "miss45654QAz/edqskey-data_sq.tdl"; + Assert.IsFalse(UrlRegexes.Domain.IsMatch(input)); + } + + [TestMethod] + public void Domain_NotSub_Test() + { + var input = ".mis$s45654QAzedqskey-data_sq.tdl"; + Assert.IsFalse(UrlRegexes.Domain.IsMatch(input)); + } + + [TestMethod] + public void Domain_NotExt_Test() + { + var input = ".mis$s45654QAzedqskey-data_sq.tdl"; + Assert.IsFalse(UrlRegexes.Domain.IsMatch(input)); + } + } +} \ No newline at end of file diff --git a/src/Tests/BirdsiteLive.Domain.Tests/TheFedInfoServiceTests.cs b/src/Tests/BirdsiteLive.Domain.Tests/TheFedInfoServiceTests.cs new file mode 100644 index 0000000..6ebc242 --- /dev/null +++ b/src/Tests/BirdsiteLive.Domain.Tests/TheFedInfoServiceTests.cs @@ -0,0 +1,36 @@ +using System; +using System.Linq; +using System.Net.Http; +using System.Threading.Tasks; +using BirdsiteLive.Common.Regexes; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace BirdsiteLive.Domain.Tests +{ + [TestClass] + public class TheFedInfoServiceTests + { + [TestMethod] + public async Task GetBslInstanceListAsyncTest() + { + var httpClientFactoryMock = new Mock(); + httpClientFactoryMock + .Setup(x => x.CreateClient(It.IsAny())) + .Returns(new HttpClient()); + + var service = new TheFedInfoService(httpClientFactoryMock.Object); + + var bslInstanceList = await service.GetBslInstanceListAsync(); + + Assert.IsTrue(bslInstanceList.Count > 0); + + foreach (var instanceInfo in bslInstanceList) + { + Assert.IsFalse(string.IsNullOrWhiteSpace(instanceInfo.Host)); + Assert.IsTrue(UrlRegexes.Domain.IsMatch(instanceInfo.Host)); + Assert.IsTrue(instanceInfo.Version > new Version(0, 1, 0)); + } + } + } +} \ No newline at end of file