diff --git a/src/BirdsiteLive.ActivityPub/ApDeserializer.cs b/src/BirdsiteLive.ActivityPub/ApDeserializer.cs new file mode 100644 index 0000000..ef978d9 --- /dev/null +++ b/src/BirdsiteLive.ActivityPub/ApDeserializer.cs @@ -0,0 +1,30 @@ +using Newtonsoft.Json; + +namespace BirdsiteLive.ActivityPub +{ + public class ApDeserializer + { + public static Activity ProcessActivity(string json) + { + var activity = JsonConvert.DeserializeObject(json); + switch (activity.type) + { + case "Follow": + return JsonConvert.DeserializeObject(json); + case "Undo": + var a = JsonConvert.DeserializeObject(json); + if(a.apObject.type == "Follow") + return JsonConvert.DeserializeObject(json); + break; + } + + return null; + } + + private class Ac : Activity + { + [JsonProperty("object")] + public Activity apObject { get; set; } + } + } +} \ No newline at end of file diff --git a/src/BirdsiteLive.ActivityPub/BirdsiteLive.ActivityPub.csproj b/src/BirdsiteLive.ActivityPub/BirdsiteLive.ActivityPub.csproj index 2632b10..01a891a 100644 --- a/src/BirdsiteLive.ActivityPub/BirdsiteLive.ActivityPub.csproj +++ b/src/BirdsiteLive.ActivityPub/BirdsiteLive.ActivityPub.csproj @@ -5,6 +5,7 @@ + diff --git a/src/BirdsiteLive.ActivityPub/Models/Activity.cs b/src/BirdsiteLive.ActivityPub/Models/Activity.cs new file mode 100644 index 0000000..8a93505 --- /dev/null +++ b/src/BirdsiteLive.ActivityPub/Models/Activity.cs @@ -0,0 +1,16 @@ +using System.Text.Json.Serialization; +using Newtonsoft.Json; + +namespace BirdsiteLive.ActivityPub +{ + public class Activity + { + [JsonProperty("@context")] + public string context { get; set; } + public string id { get; set; } + public string type { get; set; } + public string actor { get; set; } + //[JsonProperty("object")] + //public string apObject { get; set; } + } +} \ No newline at end of file diff --git a/src/BirdsiteLive.ActivityPub/Models/ActivityFollow.cs b/src/BirdsiteLive.ActivityPub/Models/ActivityFollow.cs new file mode 100644 index 0000000..26676f1 --- /dev/null +++ b/src/BirdsiteLive.ActivityPub/Models/ActivityFollow.cs @@ -0,0 +1,10 @@ +using Newtonsoft.Json; + +namespace BirdsiteLive.ActivityPub +{ + public class ActivityFollow : Activity + { + [JsonProperty("object")] + public string apObject { get; set; } + } +} \ No newline at end of file diff --git a/src/BirdsiteLive.ActivityPub/Models/ActivityUndo.cs b/src/BirdsiteLive.ActivityPub/Models/ActivityUndo.cs new file mode 100644 index 0000000..2d98b5d --- /dev/null +++ b/src/BirdsiteLive.ActivityPub/Models/ActivityUndo.cs @@ -0,0 +1,10 @@ +using Newtonsoft.Json; + +namespace BirdsiteLive.ActivityPub +{ + public class ActivityUndo : Activity + { + [JsonProperty("object")] + public Activity apObject { get; set; } + } +} \ No newline at end of file diff --git a/src/BirdsiteLive.ActivityPub/Models/ActivityUndoFollow.cs b/src/BirdsiteLive.ActivityPub/Models/ActivityUndoFollow.cs new file mode 100644 index 0000000..624988f --- /dev/null +++ b/src/BirdsiteLive.ActivityPub/Models/ActivityUndoFollow.cs @@ -0,0 +1,10 @@ +using Newtonsoft.Json; + +namespace BirdsiteLive.ActivityPub +{ + public class ActivityUndoFollow : Activity + { + [JsonProperty("object")] + public ActivityFollow apObject { get; set; } + } +} \ No newline at end of file diff --git a/src/BirdsiteLive.ActivityPub/Actor.cs b/src/BirdsiteLive.ActivityPub/Models/Actor.cs similarity index 71% rename from src/BirdsiteLive.ActivityPub/Actor.cs rename to src/BirdsiteLive.ActivityPub/Models/Actor.cs index d6e7cbd..51dc41c 100644 --- a/src/BirdsiteLive.ActivityPub/Actor.cs +++ b/src/BirdsiteLive.ActivityPub/Models/Actor.cs @@ -1,12 +1,14 @@ using System; using System.Text.Json.Serialization; +using Newtonsoft.Json; namespace BirdsiteLive.ActivityPub { public class Actor { - [JsonPropertyName("@context")] - public string[] context { get; set; } = new[] {"https://www.w3.org/ns/activitystreams", "https://w3id.org/security/v1"}; + //[JsonPropertyName("@context")] + [JsonProperty("@context")] + public object[] context { get; set; } = new[] {"https://www.w3.org/ns/activitystreams", "https://w3id.org/security/v1" }; public string id { get; set; } public string type { get; set; } public string preferredUsername { get; set; } diff --git a/src/BirdsiteLive.ActivityPub/Image.cs b/src/BirdsiteLive.ActivityPub/Models/Image.cs similarity index 100% rename from src/BirdsiteLive.ActivityPub/Image.cs rename to src/BirdsiteLive.ActivityPub/Models/Image.cs diff --git a/src/BirdsiteLive.ActivityPub/PublicKey.cs b/src/BirdsiteLive.ActivityPub/Models/PublicKey.cs similarity index 100% rename from src/BirdsiteLive.ActivityPub/PublicKey.cs rename to src/BirdsiteLive.ActivityPub/Models/PublicKey.cs diff --git a/src/BirdsiteLive.Domain/ActivityPubService.cs b/src/BirdsiteLive.Domain/ActivityPubService.cs new file mode 100644 index 0000000..e80877c --- /dev/null +++ b/src/BirdsiteLive.Domain/ActivityPubService.cs @@ -0,0 +1,26 @@ +using System.Net.Http; +using System.Threading.Tasks; +using BirdsiteLive.ActivityPub; +using Newtonsoft.Json; + +namespace BirdsiteLive.Domain +{ + public interface IActivityPubService + { + Task GetUser(string objectId); + } + + public class ActivityPubService : IActivityPubService + { + public async Task GetUser(string objectId) + { + using (var httpClient = new HttpClient()) + { + httpClient.DefaultRequestHeaders.Add("Accept", "application/json"); + var result = await httpClient.GetAsync(objectId); + var content = await result.Content.ReadAsStringAsync(); + return JsonConvert.DeserializeObject(content); + } + } + } +} \ No newline at end of file diff --git a/src/BirdsiteLive.Domain/UserService.cs b/src/BirdsiteLive.Domain/UserService.cs index 019c953..174fbab 100644 --- a/src/BirdsiteLive.Domain/UserService.cs +++ b/src/BirdsiteLive.Domain/UserService.cs @@ -1,24 +1,33 @@ using System; +using System.Collections.Generic; +using System.Security.Cryptography; +using System.Text; +using System.Threading.Tasks; using BirdsiteLive.ActivityPub; using BirdsiteLive.Common.Settings; +using BirdsiteLive.Cryptography; using BirdsiteLive.Twitter.Models; +using Tweetinvi.Core.Exceptions; namespace BirdsiteLive.Domain { public interface IUserService { Actor GetUser(TwitterUser twitterUser); + Task FollowRequestedAsync(string signature, string method, string path, string queryString, Dictionary requestHeaders, ActivityFollow activity); } public class UserService : IUserService { private readonly ICryptoService _cryptoService; + private readonly IActivityPubService _activityPubService; private readonly string _host; #region Ctor - public UserService(InstanceSettings instanceSettings, ICryptoService cryptoService) + public UserService(InstanceSettings instanceSettings, ICryptoService cryptoService, IActivityPubService activityPubService) { _cryptoService = cryptoService; + _activityPubService = activityPubService; _host = $"https://{instanceSettings.Domain.Replace("https://",string.Empty).Replace("http://", string.Empty).TrimEnd('/')}"; } #endregion @@ -53,5 +62,78 @@ namespace BirdsiteLive.Domain }; return user; } + + public async Task FollowRequestedAsync(string signature, string method, string path, string queryString, Dictionary requestHeaders, ActivityFollow activity) + { + // Validate + if (!await ValidateSignature(activity.actor, signature, method, path, queryString, requestHeaders)) return false; + + // Save Follow in DB + + // Send Accept Activity + + + throw new NotImplementedException(); + } + + private async Task ValidateSignature(string actor, string rawSig, string method, string path, string queryString, Dictionary requestHeaders) + { + var signatures = rawSig.Split(','); + var signature_header = new Dictionary(); + foreach (var signature in signatures) + { + var splitSig = signature.Replace("\"", string.Empty).Split('='); + signature_header.Add(splitSig[0], splitSig[1]); + } + + signature_header["signature"] = signature_header["signature"] + "=="; + + var key_id = signature_header["keyId"]; + var headers = signature_header["headers"]; + var algorithm = signature_header["algorithm"]; + var sig = Convert.FromBase64String(signature_header["signature"]); + + + var remoteUser = await _activityPubService.GetUser(actor); + + var toDecode = remoteUser.publicKey.publicKeyPem.Trim().Remove(0, remoteUser.publicKey.publicKeyPem.IndexOf('\n')); + toDecode = toDecode.Remove(toDecode.LastIndexOf('\n')).Replace("\n", ""); + var signKey = ASN1.ToRSA(Convert.FromBase64String(toDecode)); + + var toSign = new StringBuilder(); + //var comparisonString = headers.Split(' ').Select(signed_header_name => + //{ + // if (signed_header_name == "(request-target)") + // return "(request-target): post /inbox"; + // else + // return $"{signed_header_name}: {r.Headers[signed_header_name.ToUpperInvariant()]}"; + //}); + + foreach (var headerKey in headers.Split(' ')) + { + if (headerKey == "(request-target)") toSign.Append($"(request-target): {method.ToLower()} {path}{queryString}\n"); + else toSign.Append($"{headerKey}: {string.Join(", ", requestHeaders[headerKey])}\n"); + } + toSign.Remove(toSign.Length - 1, 1); + + //var signKey = ASN1.ToRSA(Convert.FromBase64String(toDecode)); + + //new RSACryptoServiceProvider(keyId.publicKey.publicKeyPem); + + //Create a new instance of RSACryptoServiceProvider. + RSACryptoServiceProvider key = new RSACryptoServiceProvider(); + + //Get an instance of RSAParameters from ExportParameters function. + RSAParameters RSAKeyInfo = key.ExportParameters(false); + + //Set RSAKeyInfo to the public key values. + RSAKeyInfo.Modulus = Convert.FromBase64String(toDecode); + + key.ImportParameters(RSAKeyInfo); + + var result = signKey.VerifyData(Encoding.UTF8.GetBytes(toSign.ToString()), sig, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + + return result; + } } } diff --git a/src/BirdsiteLive.sln b/src/BirdsiteLive.sln index 963429a..8e1b53b 100644 --- a/src/BirdsiteLive.sln +++ b/src/BirdsiteLive.sln @@ -21,6 +21,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BirdsiteLive.Domain", "Bird EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BirdsiteLive.ActivityPub", "BirdsiteLive.ActivityPub\BirdsiteLive.ActivityPub.csproj", "{7463E1E2-9736-4A46-8507-010BDD8ECFBB}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BirdsiteLive.ActivityPub.Tests", "Tests\BirdsiteLive.ActivityPub.Tests\BirdsiteLive.ActivityPub.Tests.csproj", "{1D713961-9926-41FF-8D6A-8A4B8D548484}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -55,6 +57,10 @@ Global {7463E1E2-9736-4A46-8507-010BDD8ECFBB}.Debug|Any CPU.Build.0 = Debug|Any CPU {7463E1E2-9736-4A46-8507-010BDD8ECFBB}.Release|Any CPU.ActiveCfg = Release|Any CPU {7463E1E2-9736-4A46-8507-010BDD8ECFBB}.Release|Any CPU.Build.0 = Release|Any CPU + {1D713961-9926-41FF-8D6A-8A4B8D548484}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1D713961-9926-41FF-8D6A-8A4B8D548484}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1D713961-9926-41FF-8D6A-8A4B8D548484}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1D713961-9926-41FF-8D6A-8A4B8D548484}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -66,6 +72,7 @@ Global {155D46A4-2D05-47F2-8FFC-0B7C412A7652} = {A32D3458-09D0-4E0A-BA4B-8C411B816B94} {D48450EE-D8BD-4228-9864-043AC88F7EE0} = {4FEAD6BC-3C8E-451A-8CA1-FF1AF47D26CC} {7463E1E2-9736-4A46-8507-010BDD8ECFBB} = {4FEAD6BC-3C8E-451A-8CA1-FF1AF47D26CC} + {1D713961-9926-41FF-8D6A-8A4B8D548484} = {A32D3458-09D0-4E0A-BA4B-8C411B816B94} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {69E8DCAD-4C37-4010-858F-5F94E6FBABCE} diff --git a/src/BirdsiteLive/Controllers/UsersController.cs b/src/BirdsiteLive/Controllers/UsersController.cs index ae275e2..bae8701 100644 --- a/src/BirdsiteLive/Controllers/UsersController.cs +++ b/src/BirdsiteLive/Controllers/UsersController.cs @@ -4,9 +4,13 @@ using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; +using BirdsiteLive.ActivityPub; using BirdsiteLive.Domain; using BirdsiteLive.Twitter; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Primitives; +using Newtonsoft.Json; namespace BirdsiteLive.Controllers { @@ -34,7 +38,8 @@ namespace BirdsiteLive.Controllers if (r.Contains("application/activity+json")) { var apUser = _userService.GetUser(user); - return Json(apUser); + var jsonApUser = JsonConvert.SerializeObject(apUser); + return Content(jsonApUser, "application/json"); } return View(user); @@ -48,11 +53,27 @@ namespace BirdsiteLive.Controllers using (var reader = new StreamReader(Request.Body)) { var body = await reader.ReadToEndAsync(); - + var activity = ApDeserializer.ProcessActivity(body); // Do something + + switch (activity.type) + { + case "Follow": + var succeeded = await _userService.FollowRequestedAsync(r.Headers["Signature"].First(), r.Method, r.Path, r.QueryString.ToString(), RequestHeaders(r.Headers), activity as ActivityFollow); + if (succeeded) return Ok(); + else return Unauthorized(); + break; + default: + return Ok(); + } } return Ok(); } + + private Dictionary RequestHeaders(IHeaderDictionary header) + { + return header.ToDictionary, string, string>(h => h.Key, h => h.Value); + } } } \ No newline at end of file diff --git a/src/Tests/BirdsiteLive.ActivityPub.Tests/ActivityTests.cs b/src/Tests/BirdsiteLive.ActivityPub.Tests/ActivityTests.cs new file mode 100644 index 0000000..1687ac4 --- /dev/null +++ b/src/Tests/BirdsiteLive.ActivityPub.Tests/ActivityTests.cs @@ -0,0 +1,33 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Newtonsoft.Json; + +namespace BirdsiteLive.ActivityPub.Tests +{ + //[TestClass] + //public class ActivityTests + //{ + // [TestMethod] + // public void FollowDeserializationTest() + // { + // var json = "{ \"@context\":\"https://www.w3.org/ns/activitystreams\",\"id\":\"https://mastodon.technology/c94567cf-1fda-42ba-82fc-a0f82f63ccbe\",\"type\":\"Follow\",\"actor\":\"https://mastodon.technology/users/testtest\",\"object\":\"https://4a120ca2680e.ngrok.io/users/manu\"}"; + + // var data = JsonConvert.DeserializeObject(json); + + // Assert.AreEqual("https://mastodon.technology/c94567cf-1fda-42ba-82fc-a0f82f63ccbe", data.id); + // Assert.AreEqual("Follow", data.type); + // Assert.AreEqual("https://4a120ca2680e.ngrok.io/users/manu", data.apObject); + // } + + // [TestMethod] + // public void UndoDeserializationTest() + // { + // var json = + // "{\"@context\":\"https://www.w3.org/ns/activitystreams\",\"id\":\"https://mastodon.technology/users/testtest#follows/225982/undo\",\"type\":\"Undo\",\"actor\":\"https://mastodon.technology/users/testtest\",\"object\":{\"id\":\"https://mastodon.technology/c94567cf-1fda-42ba-82fc-a0f82f63ccbe\",\"type\":\"Follow\",\"actor\":\"https://mastodon.technology/users/testtest\",\"object\":\"https://4a120ca2680e.ngrok.io/users/manu\"}}"; + + // var data = JsonConvert.DeserializeObject(json); + // Assert.AreEqual("https://mastodon.technology/users/testtest#follows/225982/undo", data.id); + // Assert.AreEqual("Undo", data.type); + // Assert.AreEqual("https://4a120ca2680e.ngrok.io/users/manu", data.apObject); + // } + //} +} diff --git a/src/Tests/BirdsiteLive.ActivityPub.Tests/ActorTests.cs b/src/Tests/BirdsiteLive.ActivityPub.Tests/ActorTests.cs new file mode 100644 index 0000000..337eee6 --- /dev/null +++ b/src/Tests/BirdsiteLive.ActivityPub.Tests/ActorTests.cs @@ -0,0 +1,21 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Newtonsoft.Json; + +namespace BirdsiteLive.ActivityPub.Tests +{ + [TestClass] + public class ActorTests + { + [TestMethod] + public void Deserialize() + { + var json = + "{\"@context\":[\"https://www.w3.org/ns/activitystreams\",\"https://w3id.org/security/v1\",{\"manuallyApprovesFollowers\":\"as:manuallyApprovesFollowers\",\"toot\":\"http://joinmastodon.org/ns#\",\"featured\":{\"@id\":\"toot:featured\",\"@type\":\"@id\"},\"alsoKnownAs\":{\"@id\":\"as:alsoKnownAs\",\"@type\":\"@id\"},\"movedTo\":{\"@id\":\"as:movedTo\",\"@type\":\"@id\"},\"schema\":\"http://schema.org#\",\"PropertyValue\":\"schema:PropertyValue\",\"value\":\"schema:value\",\"IdentityProof\":\"toot:IdentityProof\",\"discoverable\":\"toot:discoverable\"}],\"id\":\"https://mastodon.technology/users/testtest\",\"type\":\"Person\",\"following\":\"https://mastodon.technology/users/testtest/following\",\"followers\":\"https://mastodon.technology/users/testtest/followers\",\"inbox\":\"https://mastodon.technology/users/testtest/inbox\",\"outbox\":\"https://mastodon.technology/users/testtest/outbox\",\"featured\":\"https://mastodon.technology/users/testtest/collections/featured\",\"preferredUsername\":\"testtest\",\"name\":\"TESTEST\",\"summary\":\"\u003cp\u003etest \u003cbr /\u003edsqdq65d4sq56d456q4d8zd4q685d45qd4sqd2q1d5zq56d465qsd4q65sd21qsd23q1s5d64qsd8q465d4s5q1d6qsd35qs4dq6sd84q\u003c/p\u003e\",\"url\":\"https://mastodon.technology/@testtest\",\"manuallyApprovesFollowers\":false,\"discoverable\":false,\"publicKey\":{\"id\":\"https://mastodon.technology/users/testtest#main-key\",\"owner\":\"https://mastodon.technology/users/testtest\",\"publicKeyPem\":\"-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAm7BlbWI/UD/YJj288h/5\nFB0gXZj0BjYVaK28uzTvb4w6eMu4qpbE9NI0bFqrloXzL3z6PaOCL4Myz9uJYolE\nZ9uNVi2OeZmHigNEOT3hkJWzddtrhkg8MLXKPdOETjhVWV3n+na7QWDDIXP7Fuvi\n+osA5LOoqtD1rYs87xUcWQPLCtVHs928FXsCdLO11ofXiNrancSzY17nkuufjWO+\ndLtvz1kx4Mt2V4Fu+DHskQAzPKU2tzGBrtlVQrk+1R63psIuZYDB6e4i7L6/d1Xl\nIQGmBeJfyxiuNIlbfZIbJ3xPYBQaVAnRKtyGVEFMWwZCqMySwc2LBX+rxI20zJ0R\n7wIDAQAB\n-----END PUBLIC KEY-----\n\"},\"tag\":[],\"attachment\":[],\"endpoints\":{\"sharedInbox\":\"https://mastodon.technology/inbox\"}}"; + + + var actor = JsonConvert.DeserializeObject(json); + + + } + } +} \ No newline at end of file diff --git a/src/Tests/BirdsiteLive.ActivityPub.Tests/ApDeserializerTests.cs b/src/Tests/BirdsiteLive.ActivityPub.Tests/ApDeserializerTests.cs new file mode 100644 index 0000000..ab4441c --- /dev/null +++ b/src/Tests/BirdsiteLive.ActivityPub.Tests/ApDeserializerTests.cs @@ -0,0 +1,35 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Newtonsoft.Json; + +namespace BirdsiteLive.ActivityPub.Tests +{ + [TestClass] + public class ApDeserializerTests + { + [TestMethod] + public void FollowDeserializationTest() + { + var json = "{ \"@context\":\"https://www.w3.org/ns/activitystreams\",\"id\":\"https://mastodon.technology/c94567cf-1fda-42ba-82fc-a0f82f63ccbe\",\"type\":\"Follow\",\"actor\":\"https://mastodon.technology/users/testtest\",\"object\":\"https://4a120ca2680e.ngrok.io/users/manu\"}"; + + var data = ApDeserializer.ProcessActivity(json) as ActivityFollow; + + Assert.AreEqual("https://mastodon.technology/c94567cf-1fda-42ba-82fc-a0f82f63ccbe", data.id); + Assert.AreEqual("Follow", data.type); + Assert.AreEqual("https://4a120ca2680e.ngrok.io/users/manu", data.apObject); + } + + [TestMethod] + public void UndoDeserializationTest() + { + var json = + "{\"@context\":\"https://www.w3.org/ns/activitystreams\",\"id\":\"https://mastodon.technology/users/testtest#follows/225982/undo\",\"type\":\"Undo\",\"actor\":\"https://mastodon.technology/users/testtest\",\"object\":{\"id\":\"https://mastodon.technology/c94567cf-1fda-42ba-82fc-a0f82f63ccbe\",\"type\":\"Follow\",\"actor\":\"https://mastodon.technology/users/testtest\",\"object\":\"https://4a120ca2680e.ngrok.io/users/manu\"}}"; + + var data = ApDeserializer.ProcessActivity(json) as ActivityUndoFollow; + Assert.AreEqual("https://mastodon.technology/users/testtest#follows/225982/undo", data.id); + Assert.AreEqual("Undo", data.type); + Assert.AreEqual("Follow", data.apObject.type); + Assert.AreEqual("https://mastodon.technology/users/testtest", data.apObject.actor); + Assert.AreEqual("https://4a120ca2680e.ngrok.io/users/manu", data.apObject.apObject); + } + } +} \ No newline at end of file diff --git a/src/Tests/BirdsiteLive.ActivityPub.Tests/BirdsiteLive.ActivityPub.Tests.csproj b/src/Tests/BirdsiteLive.ActivityPub.Tests/BirdsiteLive.ActivityPub.Tests.csproj new file mode 100644 index 0000000..611d29e --- /dev/null +++ b/src/Tests/BirdsiteLive.ActivityPub.Tests/BirdsiteLive.ActivityPub.Tests.csproj @@ -0,0 +1,20 @@ + + + + netcoreapp3.1 + + false + + + + + + + + + + + + + +