using MongoDB.Entities; using NetVips; using PrivaPub.Federation.Actors; using PrivaPub.Models.Post; using PrivaPub.Models.User; using System.Net; using System.Net.Http.Headers; using System.Net.Http.Json; using System.Text; using System.Text.Json.Nodes; using System.Text.RegularExpressions; namespace PrivaPub.Tests.Support.Host { public sealed record Mastodon(Persona Persona, string Token, HttpClient Client) { public string Id => Persona.Id; public string UserName => Persona.UserName; public string Uri => $"{PrivaPubHost.Base}/peasants/{Persona.UserName}"; public string Mouth => $"/peasants/{Persona.UserName}/mouth"; } public sealed record ApiAnswer(HttpStatusCode Status, JsonNode Body, string Text, HttpResponseHeaders Headers) { public JsonObject Object => Body.AsObject(); public JsonArray Array => Body.AsArray(); public IEnumerable Ids => Array.Select(item => item!["id"]!.GetValue()); public ApiAnswer Ok() { Assert.True(Status == HttpStatusCode.OK, $"expected 200, got {(int)Status}: {Text}"); return this; } public string Link(string rel) => Headers.TryGetValues("Link", out var values) ? Regex.Matches(string.Join(", ", values), "<([^>]+)>; rel=\"([a-z]+)\"").FirstOrDefault(m => m.Groups[2].Value == rel)?.Groups[1].Value : default; } public static class MastodonHelpers { public const string Public = "https://www.w3.org/ns/activitystreams#Public"; public static async Task Mastodon(this PrivaPubHost host, string name = "persona", Root root = default, string scopes = "read write follow") { root ??= await host.SignUp(); var persona = await host.Persona(root, name); var token = await host.MastodonToken(persona, scopes); return new Mastodon(persona, token, host.As(token)); } public static Task Get(this HttpClient client, string path) => client.Call(HttpMethod.Get, path); public static Task Post(this HttpClient client, string path, params (string Key, string Value)[] form) => client.Call(HttpMethod.Post, path, form); public static Task Put(this HttpClient client, string path, params (string Key, string Value)[] form) => client.Call(HttpMethod.Put, path, form); public static Task Patch(this HttpClient client, string path, params (string Key, string Value)[] form) => client.Call(HttpMethod.Patch, path, form); public static Task Delete(this HttpClient client, string path, params (string Key, string Value)[] form) => client.Call(HttpMethod.Delete, path, form); public static Task Call(this HttpClient client, HttpMethod method, string path, params (string Key, string Value)[] form) { var request = new HttpRequestMessage(method, path); if (form.Length > 0) request.Content = new FormUrlEncodedContent(form.Select(f => new KeyValuePair(f.Key, f.Value))); return client.Exchange(request); } public static Task Json(this HttpClient client, HttpMethod method, string path, JsonNode body) => client.Exchange(new HttpRequestMessage(method, path) { Content = new StringContent(body.ToJsonString(), Encoding.UTF8, "application/json") }); public static async Task Exchange(this HttpClient client, HttpRequestMessage request) { using (request) { using var response = await client.SendAsync(request, TestContext.Current.CancellationToken); var text = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); JsonNode body = default; if (!string.IsNullOrWhiteSpace(text) && text.TrimStart()[0] is '{' or '[') body = JsonNode.Parse(text); return new ApiAnswer(response.StatusCode, body, text, response.Headers); } } public static async Task Status(this Mastodon account, string text, params (string Key, string Value)[] more) => (await account.Client.Post("/api/v1/statuses", more.Prepend(("status", text)).ToArray())).Ok().Object; public static string Text(this JsonNode node, string property) => node?[property]?.GetValue(); public static int Number(this JsonNode node, string property) => node![property]!.GetValue(); public static bool Flag(this JsonNode node, string property) => node![property]!.GetValue(); public static async Task Known(this PrivaPubHost host, RemoteActor actor) => await host.Get().GetActor(actor.Id, refresh: true, TestContext.Current.CancellationToken); public static string Handle(this RemoteActor actor) => $"{actor.Name}@{new Uri(actor.Id).Authority}"; public static string Inbox(this RemoteActor actor) => actor.Id + "/inbox"; public static string Origin(this RemoteActor actor) => actor.Id.Split("/users/")[0]; public static void WebFinger(this Peer peer, RemoteActor actor) => peer.ServeText("/.well-known/webfinger", new JsonObject { ["subject"] = $"acct:{actor.Handle()}", ["links"] = new JsonArray(new JsonObject { ["rel"] = "self", ["type"] = "application/activity+json", ["href"] = actor.Id }) }.ToJsonString(), "application/jrd+json"); public static async Task Deliver(this PrivaPubHost host, RemoteActor actor, string path, JsonObject activity) { using var client = host.Client(); using var response = await client.SendAsync(actor.SignedPost(path, activity), TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.Accepted, response.StatusCode); await host.RunInbox(activity["id"]!.GetValue(), TestContext.Current.CancellationToken); } public static async Task FollowedBy(this PrivaPubHost host, Mastodon account, RemoteActor actor) { var followId = $"{actor.Origin()}/follows/{Guid.NewGuid():N}"; await host.Deliver(actor, account.Mouth, new JsonObject { ["id"] = followId, ["type"] = "Follow", ["actor"] = actor.Id, ["object"] = account.Uri }); return followId; } public static JsonObject Create(this RemoteActor actor, string content, IEnumerable to, IEnumerable cc = default, Action shape = default) { var noteId = $"{actor.Origin()}/notes/{Guid.NewGuid():N}"; var note = new JsonObject { ["id"] = noteId, ["type"] = "Note", ["attributedTo"] = actor.Id, ["to"] = new JsonArray(to.Select(t => (JsonNode)t).ToArray()), ["cc"] = new JsonArray((cc ?? Enumerable.Empty()).Select(c => (JsonNode)c).ToArray()), ["content"] = content, ["published"] = DateTime.UtcNow.ToString("O") }; shape?.Invoke(note); return new JsonObject { ["id"] = noteId + "/activity", ["type"] = "Create", ["actor"] = actor.Id, ["to"] = note["to"]!.DeepClone(), ["cc"] = note["cc"]!.DeepClone(), ["object"] = note }; } public static async Task PublicPostFrom(this PrivaPubHost host, RemoteActor actor, Mastodon mentioned, string content = "

a remote thought

", Action shape = default) { var create = actor.Create(content, new[] { Public }, new[] { mentioned.Uri }, note => { note["tag"] = new JsonArray(new JsonObject { ["type"] = "Mention", ["href"] = mentioned.Uri, ["name"] = "@" + mentioned.UserName }); shape?.Invoke(note); }); await host.Deliver(actor, mentioned.Mouth, create); var noteId = create["object"]!["id"]!.GetValue(); return await DB.Default.Find().Match(p => p.ObjectURI == noteId).ExecuteFirstAsync(TestContext.Current.CancellationToken); } public static async Task> Delivered(this RemoteActor actor, DateTime since) => await Jobs.Deliveries(actor.Inbox(), since, TestContext.Current.CancellationToken); public static string Type(this JsonObject activity) => activity["type"]!.GetValue(); public static byte[] JpegWithMetadata(int width, int height) { using var image = (NetVips.Image.Black(width, height, bands: 3) + new double[] { 30, 140, 200 }).Cast(Enums.BandFormat.Uchar); using var tagged = image.Mutate(m => { m.Set(GValue.GStrType, "exif-ifd0-ImageDescription", "where I live"); m.Set(GValue.GStrType, "exif-ifd0-Artist", "Alice Smith"); m.Set(GValue.BlobType, "xmp-data", Encoding.UTF8.GetBytes("")); }); return tagged.WriteToBuffer(".jpg"); } public static void AssertNoMetadata(byte[] image) { using var loaded = NetVips.Image.NewFromBuffer(image); var fields = loaded.GetFields(); Assert.DoesNotContain("exif-data", fields); Assert.DoesNotContain("xmp-data", fields); Assert.DoesNotContain(fields, f => f.StartsWith("exif-ifd", StringComparison.Ordinal)); } public static MultipartFormDataContent Multipart(params (string Name, byte[] Bytes, string ContentType, string FileName)[] files) { var content = new MultipartFormDataContent(); foreach (var file in files) { var part = new ByteArrayContent(file.Bytes); part.Headers.ContentType = new MediaTypeHeaderValue(file.ContentType); content.Add(part, file.Name, file.FileName); } return content; } public static async Task ActivityPub(this PrivaPubHost host, string path) { var request = new HttpRequestMessage(HttpMethod.Get, path); request.Headers.Accept.ParseAdd("application/activity+json"); return await host.Client().Exchange(request); } public static async Task Located(this PrivaPubHost host, Mastodon account, string text) { using var client = host.As(account.Persona.Root.Jwt); var response = await client.PostAsJsonAsync("/clientapi/post/insert", new { avatarId = account.Id, text, latitude = 45.07, longitude = 7.68, rangeKm = 5 }, TestContext.Current.CancellationToken); var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); Assert.True(response.IsSuccessStatusCode, body); return JsonNode.Parse(body)!["id"]!.GetValue(); } public static string IdBefore(DateTime when) => ((int)new DateTimeOffset(when).ToUnixTimeSeconds()).ToString("x8") + "0000000000000000"; } }