Merge pull request #179 from NicolasConstant/develop

0.22.0 PR
This commit is contained in:
Nicolas Constant 2022-12-30 16:03:24 -05:00 committed by GitHub
commit 8b1a61c197
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 402 additions and 48 deletions

View file

@ -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\-_]+)+$");
}
}

View file

@ -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<MigrationService> _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<MigrationService> 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<List<BslInstanceInfo>> 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)

View file

@ -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<List<BslInstanceInfo>> GetBslInstanceListAsync();
}
public class TheFedInfoService : ITheFedInfoService
{
private readonly IHttpClientFactory _httpClientFactory;
#region Ctor
public TheFedInfoService(IHttpClientFactory httpClientFactory)
{
_httpClientFactory = httpClientFactory;
}
#endregion
public async Task<List<BslInstanceInfo>> GetBslInstanceListAsync()
{
var cancellationToken = CancellationToken.None;
var result = await CallGraphQlAsync<MyResponseData>(
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<BslInstanceInfo> ConvertResults(GraphQLResponse<MyResponseData> qlData)
{
var results = new List<BslInstanceInfo>();
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<GraphQLResponse<TResponse>> CallGraphQlAsync<TResponse>(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<TResponse>(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<TResponse> DeserializeGraphQlCall<TResponse>(string response)
{
var serializer = new JsonSerializer();
var stringReader = new StringReader(response);
var jsonReader = new JsonTextReader(stringReader);
var result = serializer.Deserialize<GraphQLResponse<TResponse>>(jsonReader);
return result;
}
private class GraphQLResponse<TResponse>
{
public List<GraphQLError> Errors { get; set; }
public TResponse Data { get; set; }
}
private class GraphQLError
{
public string Message { get; set; }
public List<GraphQLErrorLocation> Locations { get; set; }
public List<object> 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; }
}
}

View file

@ -4,7 +4,7 @@
<TargetFramework>netcoreapp3.1</TargetFramework>
<UserSecretsId>d21486de-a812-47eb-a419-05682bb68856</UserSecretsId>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
<Version>0.21.0</Version>
<Version>0.22.0</Version>
</PropertyGroup>
<ItemGroup>

View file

@ -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<IActionResult> 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<IActionResult> 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; }
}
}

View file

@ -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; }
}
}

View file

@ -1,4 +1,4 @@
@model BirdsiteLive.Controllers.MigrationData
@model MigrationData
@{
ViewData["Title"] = "Migration";
}

View file

@ -1,4 +1,4 @@
@model BirdsiteLive.Controllers.MigrationData
@model MigrationData
@{
ViewData["Title"] = "Migration";
}

View file

@ -31,13 +31,7 @@
<br />
<br />
@if (ViewData.Model.Protected)
{
<div class="alert alert-danger" role="alert">
This account is protected, BirdsiteLIVE cannot fetch their tweets and will not provide follow support until it is unprotected again.
</div>
}
else if (ViewData.Model.Deleted)
@if (ViewData.Model.Deleted)
{
<div class="alert alert-danger" role="alert">
This mirror has been deleted by its Twitter owner.
@ -50,6 +44,12 @@
You can follow this user at <a href="@ViewData.Model.MovedTo">@ViewData.Model.MovedToAcct</a>.
</div>
}
else if (ViewData.Model.Protected)
{
<div class="alert alert-danger" role="alert">
This account is protected, BirdsiteLIVE cannot fetch their tweets and will not provide follow support until it is unprotected again.
</div>
}
else
{
<div>

View file

@ -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));
}
}
}

View file

@ -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<IHttpClientFactory>();
httpClientFactoryMock
.Setup(x => x.CreateClient(It.IsAny<string>()))
.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));
}
}
}
}