232 lines
9.9 KiB
C#
232 lines
9.9 KiB
C#
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<string> Ids => Array.Select(item => item!["id"]!.GetValue<string>());
|
||
|
|
|
||
|
|
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> 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<ApiAnswer> Get(this HttpClient client, string path) => client.Call(HttpMethod.Get, path);
|
||
|
|
|
||
|
|
public static Task<ApiAnswer> Post(this HttpClient client, string path, params (string Key, string Value)[] form) => client.Call(HttpMethod.Post, path, form);
|
||
|
|
|
||
|
|
public static Task<ApiAnswer> Put(this HttpClient client, string path, params (string Key, string Value)[] form) => client.Call(HttpMethod.Put, path, form);
|
||
|
|
|
||
|
|
public static Task<ApiAnswer> Patch(this HttpClient client, string path, params (string Key, string Value)[] form) => client.Call(HttpMethod.Patch, path, form);
|
||
|
|
|
||
|
|
public static Task<ApiAnswer> Delete(this HttpClient client, string path, params (string Key, string Value)[] form) => client.Call(HttpMethod.Delete, path, form);
|
||
|
|
|
||
|
|
public static Task<ApiAnswer> 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<string, string>(f.Key, f.Value)));
|
||
|
|
return client.Exchange(request);
|
||
|
|
}
|
||
|
|
|
||
|
|
public static Task<ApiAnswer> 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<ApiAnswer> 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<JsonObject> 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<string>();
|
||
|
|
|
||
|
|
public static int Number(this JsonNode node, string property) => node![property]!.GetValue<int>();
|
||
|
|
|
||
|
|
public static bool Flag(this JsonNode node, string property) => node![property]!.GetValue<bool>();
|
||
|
|
|
||
|
|
public static async Task<ForeignAvatar> Known(this PrivaPubHost host, RemoteActor actor) =>
|
||
|
|
await host.Get<IRemoteActorService>().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<string>(), TestContext.Current.CancellationToken);
|
||
|
|
}
|
||
|
|
|
||
|
|
public static async Task<string> 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<string> to, IEnumerable<string> cc = default,
|
||
|
|
Action<JsonObject> 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<string>()).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<Post> PublicPostFrom(this PrivaPubHost host, RemoteActor actor, Mastodon mentioned, string content = "<p>a remote thought</p>",
|
||
|
|
Action<JsonObject> 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<string>();
|
||
|
|
return await DB.Default.Find<Post>().Match(p => p.ObjectURI == noteId).ExecuteFirstAsync(TestContext.Current.CancellationToken);
|
||
|
|
}
|
||
|
|
|
||
|
|
public static async Task<List<JsonObject>> 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<string>();
|
||
|
|
|
||
|
|
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("<x:xmpmeta xmlns:x='adobe:ns:meta/'><secret/></x:xmpmeta>"));
|
||
|
|
});
|
||
|
|
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<ApiAnswer> 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<string> 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<string>();
|
||
|
|
}
|
||
|
|
|
||
|
|
public static string IdBefore(DateTime when) => ((int)new DateTimeOffset(when).ToUnixTimeSeconds()).ToString("x8") + "0000000000000000";
|
||
|
|
}
|
||
|
|
}
|