diff --git a/PrivaPub.ClientModels/Group/InsertGroupForm.cs b/PrivaPub.ClientModels/Group/InsertGroupForm.cs index 217e6fc..5a610b7 100644 --- a/PrivaPub.ClientModels/Group/InsertGroupForm.cs +++ b/PrivaPub.ClientModels/Group/InsertGroupForm.cs @@ -24,6 +24,7 @@ namespace PrivaPub.ClientModels.Group public string Description { get; set; } public string InvitationPassword { get; set; } + public bool IsCommunity { get; set; } public bool IsDiscoverable { get; set; } = true; public bool ManuallyApprovesMembers { get; set; } } diff --git a/PrivaPub.ClientModels/Group/ViewGroup.cs b/PrivaPub.ClientModels/Group/ViewGroup.cs index 9a91fff..6a7ac35 100644 --- a/PrivaPub.ClientModels/Group/ViewGroup.cs +++ b/PrivaPub.ClientModels/Group/ViewGroup.cs @@ -8,6 +8,7 @@ namespace PrivaPub.ClientModels.Group public string Description { get; set; } public string Url { get; set; } public string Handle { get; set; } + public bool IsCommunity { get; set; } public bool IsDiscoverable { get; set; } public bool ManuallyApprovesMembers { get; set; } public bool IsOwner { get; set; } diff --git a/PrivaPub.Tests/Federation/InboxScenarioTests.cs b/PrivaPub.Tests/Federation/InboxScenarioTests.cs new file mode 100644 index 0000000..1dc5d19 --- /dev/null +++ b/PrivaPub.Tests/Federation/InboxScenarioTests.cs @@ -0,0 +1,205 @@ +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging.Abstractions; + +using MongoDB.Entities; + +using PrivaPub.Federation.Actors; +using PrivaPub.Federation.Inbox; +using PrivaPub.Federation.Outbox; +using PrivaPub.Models; +using PrivaPub.Models.Group; +using PrivaPub.Models.Post; +using PrivaPub.Models.User; +using PrivaPub.StaticServices; +using PrivaPub.Tests.Support; + +using System.Text.Json.Nodes; + +using GroupEntity = PrivaPub.Models.Group.Group; + +namespace PrivaPub.Tests.Federation +{ + [Trait("Category", "Integration")] + public sealed class InboxScenarioTests : IAsyncLifetime + { + const string Host = "privapub.test"; + const string Base = "https://" + Host; + + Peer _peer; + LocalActorService _local; + InboxService _inbox; + + public async ValueTask InitializeAsync() + { + Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); + _peer = await Peer.Start(); + var cache = new MemoryCache(new MemoryCacheOptions()); + _local = new LocalActorService(new DbEntities(), new StaticOptions(new AppConfiguration { BackendBaseAddress = Base })); + var remote = new RemoteActorService(Peer.Http(cache), _local, cache, new DbEntities()); + _inbox = new InboxService(new DbEntities(), _local, remote, new DeliveryService(new DbEntities()), NullLogger.Instance); + } + + public async ValueTask DisposeAsync() + { + if (_peer != default) + await _peer.DisposeAsync(); + } + + async Task LocalAvatar(string name) + { + var (privateKey, publicKey) = Keys.NewKeyPair(); + var avatar = new Avatar { UserName = $"{name}{Guid.NewGuid():N}"[..20], PrivateKey = privateKey, PublicKey = publicKey }; + await DB.Default.SaveAsync(avatar, TestContext.Current.CancellationToken); + return _local.FromAvatar(avatar); + } + + static JsonObject DirectCreate(RemoteActor author, string to, string context = default, string objectOrigin = default, string attributedTo = default) + { + var id = $"{objectOrigin ?? Origin(author.Id)}/notes/{Guid.NewGuid():N}"; + var note = new JsonObject + { + ["id"] = id, + ["type"] = "Note", + ["attributedTo"] = attributedTo ?? author.Id, + ["content"] = "

psst

", + ["to"] = new JsonArray(to), + ["cc"] = new JsonArray() + }; + if (context != default) + note["context"] = context; + return new JsonObject + { + ["id"] = $"{Origin(author.Id)}/activities/{Guid.NewGuid():N}", + ["type"] = "Create", + ["actor"] = author.Id, + ["to"] = new JsonArray(to), + ["object"] = note + }; + } + + static string Origin(string uri) => new Uri(uri).GetLeftPart(UriPartial.Authority); + + [Fact] + public async Task A_context_cannot_pull_a_stranger_into_an_existing_conversation() + { + var token = TestContext.Current.CancellationToken; + var alice = await LocalAvatar("alice"); + var bob = new RemoteActor(_peer, "bob"); + var mallory = new RemoteActor(_peer, "mallory"); + var context = $"{_peer.A}/contexts/{Guid.NewGuid():N}"; + + var first = await _inbox.Receive(bob.Post(Host, $"/peasants/{alice.UserName}/mouth", DirectCreate(bob, alice.Uri, context)), alice, token); + var injected = await _inbox.Receive(mallory.Post(Host, $"/peasants/{alice.UserName}/mouth", DirectCreate(mallory, alice.Uri, context)), alice, token); + + Assert.Equal(202, first.StatusCode); + Assert.Equal(202, injected.StatusCode); + var bobDm = await DB.Default.Find().Match(p => p.ActorURI == bob.Id).ExecuteSingleAsync(token); + var malloryDm = await DB.Default.Find().Match(p => p.ActorURI == mallory.Id).ExecuteSingleAsync(token); + Assert.NotEqual(bobDm.GroupId, malloryDm.GroupId); + var bobConversation = await DB.Default.Find().OneAsync(bobDm.GroupId, token); + Assert.DoesNotContain(bobConversation.Members, m => m.AvatarId == mallory.Id); + } + + [Fact] + public async Task Replies_between_the_same_people_land_in_the_same_conversation() + { + var token = TestContext.Current.CancellationToken; + var alice = await LocalAvatar("alice"); + var bob = new RemoteActor(_peer, "bob"); + + await _inbox.Receive(bob.Post(Host, $"/peasants/{alice.UserName}/mouth", DirectCreate(bob, alice.Uri)), alice, token); + await _inbox.Receive(bob.Post(Host, $"/peasants/{alice.UserName}/mouth", DirectCreate(bob, alice.Uri)), alice, token); + + var dms = await DB.Default.Find().Match(p => p.ActorURI == bob.Id).ExecuteAsync(token); + Assert.Equal(2, dms.Count); + Assert.Single(dms.Select(d => d.GroupId).Distinct()); + } + + [Fact] + public async Task An_activity_id_on_another_origin_is_refused() + { + var token = TestContext.Current.CancellationToken; + var alice = await LocalAvatar("alice"); + var mallory = new RemoteActor(_peer, "mallory"); + var create = DirectCreate(mallory, alice.Uri); + create["id"] = $"{_peer.B}/activities/{Guid.NewGuid():N}"; + + var result = await _inbox.Receive(mallory.Post(Host, $"/peasants/{alice.UserName}/mouth", create), alice, token); + + Assert.Equal(400, result.StatusCode); + } + + [Fact] + public async Task A_note_put_in_someone_elses_mouth_is_refused() + { + var token = TestContext.Current.CancellationToken; + var alice = await LocalAvatar("alice"); + var mallory = new RemoteActor(_peer, "mallory"); + var victim = new RemoteActor(_peer, "victim"); + + var result = await _inbox.Receive(mallory.Post(Host, $"/peasants/{alice.UserName}/mouth", + DirectCreate(mallory, alice.Uri, attributedTo: victim.Id)), alice, token); + + Assert.Equal(400, result.StatusCode); + Assert.False(await DB.Default.Find().Match(p => p.ActorURI == victim.Id).ExecuteAnyAsync(token)); + } + + [Fact] + public async Task A_cross_origin_object_is_fetched_from_its_origin_before_it_is_believed() + { + var token = TestContext.Current.CancellationToken; + var alice = await LocalAvatar("alice"); + var mallory = new RemoteActor(_peer, "mallory"); + + var result = await _inbox.Receive(mallory.Post(Host, $"/peasants/{alice.UserName}/mouth", + DirectCreate(mallory, alice.Uri, objectOrigin: _peer.B)), alice, token); + + Assert.Equal(202, result.StatusCode); + Assert.False(await DB.Default.Find().Match(p => p.ActorURI == mallory.Id).ExecuteAnyAsync(token)); + } + + [Fact] + public async Task A_bad_signature_is_a_401() + { + var token = TestContext.Current.CancellationToken; + var alice = await LocalAvatar("alice"); + var mallory = new RemoteActor(_peer, "mallory"); + var request = mallory.Post(Host, $"/peasants/{alice.UserName}/mouth", DirectCreate(mallory, alice.Uri)); + request.Headers["Signature"] = request.Headers["Signature"].ToString().Replace("signature=\"", "signature=\"AAAA"); + + Assert.Equal(401, (await _inbox.Receive(request, alice, token)).StatusCode); + } + + [Fact] + public async Task Junk_is_a_400_never_a_500() + { + var token = TestContext.Current.CancellationToken; + var alice = await LocalAvatar("alice"); + var mallory = new RemoteActor(_peer, "mallory"); + + foreach (var junk in new JsonNode[] { new JsonArray(1, 2), JsonValue.Create("x"), new JsonObject { ["type"] = "Create" } }) + Assert.Equal(400, (await _inbox.Receive(mallory.Post(Host, $"/peasants/{alice.UserName}/mouth", junk), alice, token)).StatusCode); + } + + [Fact] + public async Task A_circle_is_not_a_federated_actor() + { + var token = TestContext.Current.CancellationToken; + var (privateKey, publicKey) = Keys.NewKeyPair(); + var circle = new GroupEntity { UserName = $"circle{Guid.NewGuid():N}"[..20], PrivateKey = privateKey, PublicKey = publicKey }; + await DB.Default.SaveAsync(circle, token); + var bob = new RemoteActor(_peer, "bob"); + var actor = _local.FromGroup(circle); + var follow = new JsonObject + { + ["id"] = $"{bob.Id}/follows/{Guid.NewGuid():N}", + ["type"] = "Follow", + ["actor"] = bob.Id, + ["object"] = actor.Uri + }; + + Assert.False(actor.IsFederated); + Assert.Equal(404, (await _inbox.Receive(bob.Post(Host, "/human-centipede", follow), default, token)).StatusCode); + } + } +} diff --git a/PrivaPub.Tests/Support/RemoteActor.cs b/PrivaPub.Tests/Support/RemoteActor.cs new file mode 100644 index 0000000..e0dc90c --- /dev/null +++ b/PrivaPub.Tests/Support/RemoteActor.cs @@ -0,0 +1,65 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; + +using PrivaPub.Federation.Signing; + +using System.Globalization; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json.Nodes; + +namespace PrivaPub.Tests.Support +{ + public sealed class RemoteActor + { + readonly RSA _key = RSA.Create(2048); + + public RemoteActor(Peer peer, string name, string origin = default) + { + Name = $"{name}{Guid.NewGuid():N}"[..20]; + Id = $"{origin ?? peer.A}/users/{Name}"; + peer.Serve($"/users/{Name}", Document().ToJsonString()); + } + + public string Name { get; } + public string Id { get; } + public string KeyId => Id + "#main-key"; + + public JsonObject Document() => new() + { + ["id"] = Id, + ["type"] = "Person", + ["preferredUsername"] = Name, + ["inbox"] = Id + "/inbox", + ["followers"] = Id + "/followers", + ["publicKey"] = new JsonObject + { + ["id"] = KeyId, + ["owner"] = Id, + ["publicKeyPem"] = _key.ExportSubjectPublicKeyInfoPem() + } + }; + + public HttpRequest Post(string host, string path, JsonNode activity) + { + var body = Encoding.UTF8.GetBytes(activity.ToJsonString()); + var date = DateTimeOffset.UtcNow.ToString("r", CultureInfo.InvariantCulture); + var digest = HttpSignatures.Digest(body); + var signingString = $"(request-target): post {path}\nhost: {host}\ndate: {date}\ndigest: {digest}"; + var signature = Convert.ToBase64String(_key.SignData(Encoding.UTF8.GetBytes(signingString), HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1)); + + var context = new DefaultHttpContext(); + context.Request.Method = "POST"; + context.Request.Host = new HostString(host); + context.Request.Path = path; + context.Features.Get().RawTarget = path; + context.Request.Headers["Date"] = date; + context.Request.Headers["Digest"] = digest; + context.Request.Headers["Content-Type"] = "application/activity+json"; + context.Request.Headers["Signature"] = $"keyId=\"{KeyId}\",algorithm=\"rsa-sha256\",headers=\"(request-target) host date digest\",signature=\"{signature}\""; + context.Request.Body = new MemoryStream(body); + context.Request.ContentLength = body.Length; + return context.Request; + } + } +} diff --git a/PrivaPub/Federation/Actors/LocalActorService.cs b/PrivaPub/Federation/Actors/LocalActorService.cs index 347a746..a20a9bb 100644 --- a/PrivaPub/Federation/Actors/LocalActorService.cs +++ b/PrivaPub/Federation/Actors/LocalActorService.cs @@ -4,6 +4,7 @@ using MongoDB.Entities; using PrivaPub.Models; using PrivaPub.Models.Federation; +using PrivaPub.Models.Group; using PrivaPub.Models.User; using PrivaPub.StaticServices; @@ -26,6 +27,7 @@ namespace PrivaPub.Federation.Actors public string PublicKeyPem { get; init; } public bool Discoverable { get; init; } = true; public bool ManuallyApprovesFollowers { get; init; } + public bool IsFederated { get; init; } = true; public DateTime Published { get; init; } public string BaseAddress { get; init; } @@ -182,6 +184,7 @@ namespace PrivaPub.Federation.Actors PublicKeyPem = group.PublicKey, Discoverable = group.IsDiscoverable, ManuallyApprovesFollowers = group.ManuallyApprovesMembers, + IsFederated = group.Kind == GroupKind.Community, Published = group.CreationDate, BaseAddress = BaseAddress }; diff --git a/PrivaPub/Federation/Controllers/PeasantsController.cs b/PrivaPub/Federation/Controllers/PeasantsController.cs index 549e5ac..c23f67a 100644 --- a/PrivaPub/Federation/Controllers/PeasantsController.cs +++ b/PrivaPub/Federation/Controllers/PeasantsController.cs @@ -38,23 +38,23 @@ namespace PrivaPub.Federation.Controllers public async Task GetActor(string actor, CancellationToken token) { var local = await _localActors.FindByUserName(actor, token); - return local == default ? NotFound() : Activity(ActivityPubRenderer.Actor(local)); + return local is not { IsFederated: true } ? NotFound() : Activity(ActivityPubRenderer.Actor(local)); } [HttpGet, Route("{actor}/anus")] public async Task Outbox(string actor, CancellationToken token) { var local = await _localActors.FindByUserName(actor, token); - if (local == default) + if (local is not { IsFederated: true }) return NotFound(); var total = local.Kind == LocalActorKind.Group - ? await DB.Default.CountAsync(p => p.GroupId == local.Id, token) - : await DB.Default.CountAsync(p => p.GroupUserId == local.Id && !p.IsFederatedCopy, token); + ? await DB.Default.CountAsync(p => p.GroupId == local.Id && !p.IsLocalOnly, token) + : await DB.Default.CountAsync(p => p.GroupUserId == local.Id && !p.IsFederatedCopy && !p.IsLocalOnly, token); var latest = await (local.Kind == LocalActorKind.Group - ? _dbEntities.Posts.Match(p => p.GroupId == local.Id) - : _dbEntities.Posts.Match(p => p.GroupUserId == local.Id && !p.IsFederatedCopy)) + ? _dbEntities.Posts.Match(p => p.GroupId == local.Id && !p.IsLocalOnly) + : _dbEntities.Posts.Match(p => p.GroupUserId == local.Id && !p.IsFederatedCopy && !p.IsLocalOnly)) .Sort(p => p.CreationDate, Order.Descending) .Limit(OutboxSize) .ExecuteAsync(token); @@ -79,7 +79,7 @@ namespace PrivaPub.Federation.Controllers public async Task Followers(string actor, CancellationToken token) { var local = await _localActors.FindByUserName(actor, token); - if (local == default) + if (local is not { IsFederated: true }) return NotFound(); var count = await DB.Default.CountAsync( f => f.LocalActorId == local.Id && f.LocalActorKind == local.Kind && f.IsAccepted, token); @@ -90,7 +90,7 @@ namespace PrivaPub.Federation.Controllers public async Task Following(string actor, CancellationToken token) { var local = await _localActors.FindByUserName(actor, token); - return local == default ? NotFound() : Activity(ActivityPubRenderer.OrderedCollection(local.Following, 0, Enumerable.Empty())); + return local is not { IsFederated: true } ? NotFound() : Activity(ActivityPubRenderer.OrderedCollection(local.Following, 0, Enumerable.Empty())); } [HttpGet, Route("{actor}/posts/{postId}")] @@ -101,7 +101,7 @@ namespace PrivaPub.Federation.Controllers return NotFound(); var post = await _dbEntities.Posts - .Match(p => p.ID == postId && p.GroupUserId == local.Id && !p.IsFederatedCopy) + .Match(p => p.ID == postId && p.GroupUserId == local.Id && !p.IsFederatedCopy && !p.IsLocalOnly) .ExecuteFirstAsync(token); if (post == default) return NotFound(); @@ -117,7 +117,7 @@ namespace PrivaPub.Federation.Controllers public async Task Inbox(string actor, CancellationToken token) { var local = await _localActors.FindByUserName(actor, token); - if (local == default) + if (local is not { IsFederated: true }) return NotFound(); return Answer(await _inbox.Receive(Request, local, token)); } diff --git a/PrivaPub/Federation/Controllers/WellKnownController.cs b/PrivaPub/Federation/Controllers/WellKnownController.cs index 27e951e..be8b668 100644 --- a/PrivaPub/Federation/Controllers/WellKnownController.cs +++ b/PrivaPub/Federation/Controllers/WellKnownController.cs @@ -43,7 +43,7 @@ namespace PrivaPub.Federation.Controllers else actor = await _localActors.FindByUri(resource, token); - if (actor == default) + if (actor is not { IsFederated: true }) return NotFound(); var document = new JsonObject diff --git a/PrivaPub/Federation/Inbox/InboxService.cs b/PrivaPub/Federation/Inbox/InboxService.cs index 5991a54..8748d32 100644 --- a/PrivaPub/Federation/Inbox/InboxService.cs +++ b/PrivaPub/Federation/Inbox/InboxService.cs @@ -117,7 +117,7 @@ namespace PrivaPub.Federation.Inbox async Task Follow(JsonNode follow, ForeignAvatar follower, CancellationToken token) { var target = await _localActors.FindByUri(Id(follow["object"]), token); - if (target == default || target.Kind == LocalActorKind.Application) + if (target is not { IsFederated: true } || target.Kind == LocalActorKind.Application) return new(StatusCodes.Status404NotFound, "no such local actor"); var existing = await _dbEntities.Followers @@ -197,7 +197,7 @@ namespace PrivaPub.Federation.Inbox foreach (var uri in addressed.Concat(new[] { Id(note["audience"]) }).Where(u => u != default).Distinct()) { var local = await _localActors.FindByUri(uri, token); - if (local != default && localTargets.All(l => l.Id != local.Id)) + if (local is { IsFederated: true } && localTargets.All(l => l.Id != local.Id)) localTargets.Add(local); } @@ -243,8 +243,12 @@ namespace PrivaPub.Federation.Inbox if (await _dbEntities.DmPosts.Match(p => p.ObjectURI == objectUri).ExecuteAnyAsync(token)) return new(StatusCodes.Status202Accepted); - var participants = addressed.Where(a => a != ActivityPubRenderer.Public).Append(author.ActorURI).ToList(); - var dmGroup = await FindOrCreateDmGroup(participants, Value(note, "context") ?? Value(note, "conversation"), token); + var participants = recipients.Select(r => r.Uri) + .Concat(addressed.Where(a => a != ActivityPubRenderer.Public && !a.EndsWith("/followers") && !a.EndsWith("/following"))) + .Append(author.ActorURI) + .ToList(); + var context = Value(note, "context") ?? Value(note, "conversation"); + var dmGroup = await FindOrCreateDmGroup(participants, Origin.Same(context, author.ActorURI) ? context : default, token); var dmHtml = ContentSanitizer.Html(Value(note, "content")); var dm = new DmPostEntity { @@ -349,27 +353,19 @@ namespace PrivaPub.Federation.Inbox foreach (var uri in participantUris.Distinct(StringComparer.Ordinal)) { var local = await _localActors.FindByUri(uri, token); - members.Add(local != default && local.Kind == LocalActorKind.Person + var member = local != default && local.Kind == LocalActorKind.Person ? new GroupMember { AvatarId = local.Id } - : new GroupMember { AvatarId = uri, IsForeign = true }); + : new GroupMember { AvatarId = uri, IsForeign = true }; + if (members.All(m => m.AvatarId != member.AvatarId || m.IsForeign != member.IsForeign)) + members.Add(member); } - if (!string.IsNullOrEmpty(context)) - { - var byContext = await _dbEntities.DmGroups.Match(g => g.ConversationURI == context).ExecuteFirstAsync(token); - if (byContext != default) - return byContext; - } - - var keys = members.Select(m => m.AvatarId).OrderBy(k => k, StringComparer.Ordinal).ToList(); - var candidates = await _dbEntities.DmGroups - .Match(g => !g.DeletionAt.HasValue && g.Members.Count == keys.Count) - .ExecuteAsync(token); - var match = candidates.FirstOrDefault(g => g.Members.Select(m => m.AvatarId).OrderBy(k => k, StringComparer.Ordinal).SequenceEqual(keys)); + var key = DmGroup.KeyOf(members); + var match = await _dbEntities.DmGroups.Match(g => g.ParticipantsKey == key && !g.DeletionAt.HasValue).ExecuteFirstAsync(token); if (match != default) return match; - var dmGroup = new DmGroup { Members = members, ConversationURI = context }; + var dmGroup = new DmGroup { Members = members, ConversationURI = context, ParticipantsKey = key }; await DB.Default.SaveAsync(dmGroup, token); return dmGroup; } diff --git a/PrivaPub/Infrastructure/Data/Migrations/_003_conversation_keys_and_circles.cs b/PrivaPub/Infrastructure/Data/Migrations/_003_conversation_keys_and_circles.cs new file mode 100644 index 0000000..971c39a --- /dev/null +++ b/PrivaPub/Infrastructure/Data/Migrations/_003_conversation_keys_and_circles.cs @@ -0,0 +1,28 @@ +using MongoDB.Entities; + +using PrivaPub.Models.Group; +using PrivaPub.Models.Post; + +namespace PrivaPub.Infrastructure.Data.Migrations +{ + public class _003_conversation_keys_and_circles : IMigration + { + public async Task UpgradeAsync() + { + foreach (var dmGroup in await DB.Default.Find().ExecuteAsync()) + await DB.Default.Update().MatchID(dmGroup.ID) + .Modify(g => g.ParticipantsKey, DmGroup.KeyOf(dmGroup.Members)) + .ExecuteAsync(); + + await DB.Default.Update() + .Match(_ => true) + .Modify(g => g.Kind, GroupKind.Circle) + .ExecuteAsync(); + + await DB.Default.Update() + .Match(p => p.GroupId != null && !p.IsFederatedCopy) + .Modify(p => p.IsLocalOnly, true) + .ExecuteAsync(); + } + } +} diff --git a/PrivaPub/Models/Group/DmGroup.cs b/PrivaPub/Models/Group/DmGroup.cs index d3f6935..eda8594 100644 --- a/PrivaPub/Models/Group/DmGroup.cs +++ b/PrivaPub/Models/Group/DmGroup.cs @@ -1,5 +1,8 @@ using MongoDB.Entities; +using System.Security.Cryptography; +using System.Text; + namespace PrivaPub.Models.Group { public class DmGroup : Entity @@ -7,9 +10,14 @@ namespace PrivaPub.Models.Group public string Description { get; set; } public List Members { get; set; } = new(); public string ConversationURI { get; set; }//context of the federated thread + public string ParticipantsKey { get; set; } public DateTime CreationDate { get; set; } = DateTime.UtcNow; public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; public DateTime? DeletionAt { get; set; } + + public static string KeyOf(IEnumerable members) => + Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(string.Join("\n", + members.Select(m => (m.IsForeign ? "remote:" : "local:") + m.AvatarId).Distinct().Order(StringComparer.Ordinal))))); } } diff --git a/PrivaPub/Models/Group/Group.cs b/PrivaPub/Models/Group/Group.cs index f72582f..c104ddf 100644 --- a/PrivaPub/Models/Group/Group.cs +++ b/PrivaPub/Models/Group/Group.cs @@ -17,6 +17,7 @@ namespace PrivaPub.Models.Group public string PrivateKey { get; set; } public string PublicKey { get; set; } + public GroupKind Kind { get; set; } public bool IsDiscoverable { get; set; } = true; public bool ManuallyApprovesMembers { get; set; } public string OwnerAvatarId { get; set; } @@ -38,6 +39,12 @@ namespace PrivaPub.Models.Group public DateTime JoinedAt { get; set; } = DateTime.UtcNow; } + public enum GroupKind + { + Circle, + Community + } + public enum GroupRole { Member, diff --git a/PrivaPub/Models/Post/Post.cs b/PrivaPub/Models/Post/Post.cs index c8b52e7..0665fc4 100644 --- a/PrivaPub/Models/Post/Post.cs +++ b/PrivaPub/Models/Post/Post.cs @@ -15,6 +15,7 @@ namespace PrivaPub.Models.Post public List Location { get; set; } = new(); public float RangeKm { get; set; } = 5.0f; public bool HasContentWarning { get; set; } = false; + public bool IsLocalOnly { get; set; } public bool IsFederatedCopy { get; set; } public string ObjectURI { get; set; }//the Note's id diff --git a/PrivaPub/Services/GroupUsersService.cs b/PrivaPub/Services/GroupUsersService.cs index e9a1dc8..908d39b 100644 --- a/PrivaPub/Services/GroupUsersService.cs +++ b/PrivaPub/Services/GroupUsersService.cs @@ -115,6 +115,7 @@ namespace PrivaPub.Services Domain = _localActors.BaseAddress, PrivateKey = privateKey, PublicKey = publicKey, + Kind = form.IsCommunity ? GroupKind.Community : GroupKind.Circle, IsDiscoverable = form.IsDiscoverable, ManuallyApprovesMembers = form.ManuallyApprovesMembers, OwnerAvatarId = form.AvatarId, @@ -326,6 +327,7 @@ namespace PrivaPub.Services Description = group.Description, Url = actor.Uri, Handle = actor.Handle, + IsCommunity = group.Kind == GroupKind.Community, IsDiscoverable = group.IsDiscoverable, ManuallyApprovesMembers = group.ManuallyApprovesMembers, IsOwner = group.OwnerAvatarId == avatarId, diff --git a/PrivaPub/Services/PostsService.cs b/PrivaPub/Services/PostsService.cs index 74d16a6..6062a68 100644 --- a/PrivaPub/Services/PostsService.cs +++ b/PrivaPub/Services/PostsService.cs @@ -65,6 +65,7 @@ namespace PrivaPub.Services return result.Invalidate(_localizer["Avatar not found."], StatusCodes.Status404NotFound); LocalActor group = default; + var isLocalOnly = false; if (!string.IsNullOrEmpty(form.GroupId)) { var groupEntity = await _dbEntities.Groups.MatchID(form.GroupId).ExecuteFirstAsync(token); @@ -72,6 +73,7 @@ namespace PrivaPub.Services || !groupEntity.Members.Any(m => !m.IsForeign && m.AvatarId == form.AvatarId)) return result.Invalidate(_localizer["Group not found."], StatusCodes.Status404NotFound); group = _localActors.FromGroup(groupEntity); + isLocalOnly = !group.IsFederated; } var post = new PostEntity @@ -84,12 +86,19 @@ namespace PrivaPub.Services ContentFormat = ContentFormat.Markdown, HasContentWarning = form.HasContentWarning, AnsweringToPostId = form.AnsweringToPostId, + IsLocalOnly = isLocalOnly, ActorURI = author.Uri }; post.ID = (string)post.GenerateNewID(); post.ObjectURI = author.PostUri(post.ID); await DB.Default.SaveAsync(post, token); + if (isLocalOnly) + { + result.Data = ToView(post); + return result; + } + var inReplyTo = await ReplyTarget(form.AnsweringToPostId, token); var note = ActivityPubRenderer.Note(post, author, group, inReplyTo); var create = ActivityPubRenderer.Create(author, note, $"create-{post.ID}"); @@ -123,6 +132,8 @@ namespace PrivaPub.Services return result.Invalidate(_localizer["Post not found."], StatusCodes.Status404NotFound); await DB.Default.DeleteAsync(post.ID); + if (post.IsLocalOnly) + return result; var delete = ActivityPubRenderer.Delete(author, post.ObjectURI, $"delete-{post.ID}", new JsonArray(ActivityPubRenderer.Public), new JsonArray(author.Followers)); @@ -205,10 +216,15 @@ namespace PrivaPub.Services if (members.All(m => m.AvatarId != member.AvatarId)) members.Add(member); } - dmGroup = new DmGroup { Members = members }; - dmGroup.ID = (string)dmGroup.GenerateNewID(); - dmGroup.ConversationURI = $"{author.Uri}/conversations/{dmGroup.ID}"; - await DB.Default.SaveAsync(dmGroup, token); + var key = DmGroup.KeyOf(members); + dmGroup = await _dbEntities.DmGroups.Match(g => g.ParticipantsKey == key && !g.DeletionAt.HasValue).ExecuteFirstAsync(token); + if (dmGroup == default) + { + dmGroup = new DmGroup { Members = members, ParticipantsKey = key }; + dmGroup.ID = (string)dmGroup.GenerateNewID(); + dmGroup.ConversationURI = $"{author.Uri}/conversations/{dmGroup.ID}"; + await DB.Default.SaveAsync(dmGroup, token); + } } var dm = new DmPostEntity