diff --git a/PrivaPub.ClientModels/Post/InsertPostForm.cs b/PrivaPub.ClientModels/Post/InsertPostForm.cs index 24b0a23..978823a 100644 --- a/PrivaPub.ClientModels/Post/InsertPostForm.cs +++ b/PrivaPub.ClientModels/Post/InsertPostForm.cs @@ -24,6 +24,15 @@ namespace PrivaPub.ClientModels.Post public string SpoilerText { get; set; } public string Visibility { get; set; }//public (default), unlisted, followersonly + + [Range(-90, 90, ErrorMessageResourceName = "Range", ErrorMessageResourceType = typeof(ErrorsResource))] + public double? Latitude { get; set; } + + [Range(-180, 180, ErrorMessageResourceName = "Range", ErrorMessageResourceType = typeof(ErrorsResource))] + public double? Longitude { get; set; } + + [Range(1, 50, ErrorMessageResourceName = "Range", ErrorMessageResourceType = typeof(ErrorsResource))] + public double? RangeKm { get; set; } } public class UpdatePostForm diff --git a/PrivaPub.ClientModels/Post/ViewPost.cs b/PrivaPub.ClientModels/Post/ViewPost.cs index 9f7a833..f8dd3aa 100644 --- a/PrivaPub.ClientModels/Post/ViewPost.cs +++ b/PrivaPub.ClientModels/Post/ViewPost.cs @@ -17,6 +17,8 @@ namespace PrivaPub.ClientModels.Post public bool IsFederatedCopy { get; set; } public DateTime CreationDate { get; set; } public ViewPost ReblogOf { get; set; } + public double? RangeKm { get; set; } + public double? DistanceKm { get; set; } } public class ViewDmGroup diff --git a/PrivaPub.Tests/Domain/NearbyTests.cs b/PrivaPub.Tests/Domain/NearbyTests.cs new file mode 100644 index 0000000..29f0bb6 --- /dev/null +++ b/PrivaPub.Tests/Domain/NearbyTests.cs @@ -0,0 +1,77 @@ +using MongoDB.Entities; + +using PrivaPub.ClientModels.Post; +using PrivaPub.Domain.Privacy; +using PrivaPub.Federation.Outbox; +using PrivaPub.Models.Jobs; +using PrivaPub.Models.Post; +using PrivaPub.Tests.Support; + +using System.Text.Json; + +namespace PrivaPub.Tests.Domain +{ + [Trait("Category", "Integration")] + public sealed class NearbyTests : IAsyncLifetime + { + Harness _harness; + + public async ValueTask InitializeAsync() + { + Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); + _harness = await Harness.Start(); + } + + public async ValueTask DisposeAsync() + { + if (_harness != default) + await _harness.DisposeAsync(); + } + + [Fact] + public async Task A_located_post_reaches_only_those_within_its_radius_and_never_leaves_the_server() + { + var token = TestContext.Current.CancellationToken; + var latitude = 10 + Random.Shared.NextDouble() * 50; + var longitude = 10 + Random.Shared.NextDouble() * 50; + var (aliceRoot, alice) = await _harness.Persona("alice"); + var (bobRoot, bob) = await _harness.Persona("bob"); + var (carolRoot, carol) = await _harness.Persona("carol"); + var follower = new RemoteActor(_harness.Peer, "follower"); + await _harness.FollowedBy(alice, follower); + + var created = await _harness.Posts.InsertPost(aliceRoot, new InsertPostForm + { + AvatarId = alice.Id, Text = "free bread at the bakery", Latitude = latitude, Longitude = longitude, RangeKm = 5 + }, token); + var post = await DB.Default.Find().OneAsync(((ViewPost)created.Data).Id, token); + + var three = (List)(await _harness.Posts.Nearby(bobRoot, bob.Id, latitude + 0.027, longitude, token)).Data; + var eight = (List)(await _harness.Posts.Nearby(carolRoot, carol.Id, latitude + 0.072, longitude, token)).Data; + + Assert.Equal(PostVisibility.LocalGeo, post.Visibility); + Assert.Equal(Math.Round(latitude, 2), post.Geo.Coordinates[1]); + Assert.Contains(three, p => p.Id == post.ID); + Assert.InRange(three.First(p => p.Id == post.ID).DistanceKm.Value, 2.0, 4.0); + Assert.DoesNotContain(eight, p => p.Id == post.ID); + Assert.False(await VisibilityPolicy.CanSee(post, bob.Id, token)); + var outgoing = (await DB.Default.Find().Match(j => j.Kind == JobKind.Deliver).ExecuteAsync(token)) + .Select(j => JsonSerializer.Deserialize(j.Payload)) + .Where(p => p.Body.Contains(post.ID)); + Assert.Empty(outgoing); + } + + [Fact] + public async Task A_located_post_cannot_be_direct_or_in_a_group() + { + var (root, alice) = await _harness.Persona("alice"); + + var result = await _harness.Statuses.Publish(alice, new PrivaPub.Domain.Statuses.StatusDraft + { + Text = "x", Latitude = 45, Longitude = 7, Visibility = PostVisibility.Direct + }, TestContext.Current.CancellationToken); + + Assert.False(result.Ok); + } + } +} diff --git a/PrivaPub/Controllers/ClientToServer/PostController.cs b/PrivaPub/Controllers/ClientToServer/PostController.cs index 075aa60..2af3ea4 100644 --- a/PrivaPub/Controllers/ClientToServer/PostController.cs +++ b/PrivaPub/Controllers/ClientToServer/PostController.cs @@ -32,6 +32,10 @@ namespace PrivaPub.Controllers.ClientToServer public async Task Insert(InsertPostForm form, CancellationToken token) => !ModelState.IsValid ? Invalid() : Answer(await _postsService.InsertPost(User.GetUserId(), form, token)); + [HttpGet, Route("/clientapi/post/nearby")] + public async Task Nearby([FromQuery] string avatarId, [FromQuery] double latitude, [FromQuery] double longitude, CancellationToken token) => + Answer(await _postsService.Nearby(User.GetUserId(), avatarId, latitude, longitude, token)); + [HttpPost, Route("/clientapi/post/update")] public async Task Update(UpdatePostForm form, CancellationToken token) => !ModelState.IsValid ? Invalid() : Answer(await _postsService.UpdatePost(User.GetUserId(), form, token)); diff --git a/PrivaPub/Domain/Statuses/StatusService.cs b/PrivaPub/Domain/Statuses/StatusService.cs index 9e7d138..733609f 100644 --- a/PrivaPub/Domain/Statuses/StatusService.cs +++ b/PrivaPub/Domain/Statuses/StatusService.cs @@ -36,6 +36,9 @@ namespace PrivaPub.Domain.Statuses public string ConversationId { get; init; } public IReadOnlyList Recipients { get; init; } = Array.Empty(); public IReadOnlyList MediaIds { get; init; } + public double? Latitude { get; init; } + public double? Longitude { get; init; } + public double? RangeKm { get; init; } } public sealed record StatusOutcome(PostEntity Post, int Status = StatusCodes.Status200OK, string Error = default) @@ -102,9 +105,16 @@ namespace PrivaPub.Domain.Statuses if (parent != default && !await VisibilityPolicy.CanSee(parent, author.Id, token)) return StatusOutcome.Fail(StatusCodes.Status404NotFound, "Record not found"); + var located = draft.Latitude.HasValue || draft.Longitude.HasValue; + if (located && (draft.Latitude is not (>= -90 and <= 90) || draft.Longitude is not (>= -180 and <= 180) || group != default + || draft.Visibility == PostVisibility.Direct)) + return StatusOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: A located post needs a valid position and no group or recipients"); + var rendered = draft.PlainText ? await _content.PlainText(draft.Text ?? string.Empty, token) : await _content.Markdown(draft.Text ?? string.Empty, token); - var isLocalOnly = group is { IsFederated: false }; - var visibility = isLocalOnly ? PostVisibility.Circle : draft.Visibility is PostVisibility.Circle or PostVisibility.LocalGeo ? PostVisibility.Public : draft.Visibility; + var isLocalOnly = group is { IsFederated: false } || located; + var visibility = located ? PostVisibility.LocalGeo + : isLocalOnly ? PostVisibility.Circle + : draft.Visibility is PostVisibility.Circle or PostVisibility.LocalGeo ? PostVisibility.Public : draft.Visibility; var post = new PostEntity { GroupUserId = author.Id, @@ -130,6 +140,11 @@ namespace PrivaPub.Domain.Statuses post.ID = (string)post.GenerateNewID(); post.ObjectURI = author.PostUri(post.ID); post.Url = author.PostHtmlUrl(post.ID); + if (located) + { + post.Geo = new Coordinates2D(Math.Round(draft.Longitude.Value, 2), Math.Round(draft.Latitude.Value, 2)); + post.RangeKm = (float)Math.Clamp(draft.RangeKm ?? 5, 1, 50); + } JsonObject create = default; if (visibility == PostVisibility.Direct) diff --git a/PrivaPub/Infrastructure/Data/Indexes.cs b/PrivaPub/Infrastructure/Data/Indexes.cs index 6bdaf84..ac29e5c 100644 --- a/PrivaPub/Infrastructure/Data/Indexes.cs +++ b/PrivaPub/Infrastructure/Data/Indexes.cs @@ -21,6 +21,7 @@ namespace PrivaPub.Infrastructure.Data await Plain(token, p => p.ActorURI); await Plain(token, p => p.ConversationId, p => p.ID); await Plain(token, p => p.InReplyToURI); + await DB.Default.Index().Key(p => p.Geo, KeyType.Geo2DSphere).CreateAsync(token); await Unique(p => p.ObjectURI, Builders.Filter.Type(p => p.ObjectURI, BsonType.String), token); await Plain(token, p => p.GroupId, p => p.ID); diff --git a/PrivaPub/Models/Post/Post.cs b/PrivaPub/Models/Post/Post.cs index 6c780c9..7bed0d3 100644 --- a/PrivaPub/Models/Post/Post.cs +++ b/PrivaPub/Models/Post/Post.cs @@ -1,3 +1,4 @@ +using MongoDB.Bson.Serialization.Attributes; using MongoDB.Entities; namespace PrivaPub.Models.Post @@ -24,6 +25,10 @@ namespace PrivaPub.Models.Post public List Media { get; set; } = new(); public List Location { get; set; } = new(); public float RangeKm { get; set; } = 5.0f; + [BsonIgnoreIfNull] + public Coordinates2D Geo { get; set; } + [BsonIgnoreIfNull] + public double? Distance { get; set; } public bool HasContentWarning { get; set; } = false; public bool IsLocalOnly { get; set; } diff --git a/PrivaPub/Services/PostsService.cs b/PrivaPub/Services/PostsService.cs index 27c08c0..b976796 100644 --- a/PrivaPub/Services/PostsService.cs +++ b/PrivaPub/Services/PostsService.cs @@ -1,5 +1,6 @@ using Microsoft.Extensions.Localization; +using MongoDB.Driver; using MongoDB.Entities; using PrivaPub.ClientModels; @@ -27,6 +28,7 @@ namespace PrivaPub.Services Task InsertPost(string rootUserId, InsertPostForm form, CancellationToken token); Task DeletePost(string rootUserId, DeletePostForm form, CancellationToken token); Task UpdatePost(string rootUserId, UpdatePostForm form, CancellationToken token); + Task Nearby(string rootUserId, string avatarId, double latitude, double longitude, CancellationToken token); Task GetPosts(string rootUserId, string avatarId, string groupId, CancellationToken token); Task InsertDm(string rootUserId, InsertDmForm form, CancellationToken token); Task GetDms(string rootUserId, string avatarId, string dmGroupId, CancellationToken token); @@ -68,7 +70,10 @@ namespace PrivaPub.Services Sensitive = form.HasContentWarning, Visibility = LocalVisibility(form.Visibility), InReplyTo = form.AnsweringToPostId, - GroupId = form.GroupId + GroupId = form.GroupId, + Latitude = form.Latitude, + Longitude = form.Longitude, + RangeKm = form.RangeKm }, token), nameof(InsertPost)); } @@ -193,6 +198,35 @@ namespace PrivaPub.Services + public async Task Nearby(string rootUserId, string avatarId, double latitude, double longitude, CancellationToken token) + { + var result = new WebResult(); + var viewer = await OwnedAvatar(rootUserId, avatarId, token); + if (viewer == default) + return result.Invalidate(_localizer["Avatar not found."], StatusCodes.Status404NotFound); + if (latitude is < -90 or > 90 || longitude is < -180 or > 180) + return result.Invalidate(_localizer["Invalid model."]); + + var query = new MongoDB.Bson.BsonDocument + { + ["Visibility"] = (int)PostVisibility.LocalGeo, + ["DeletedAt"] = MongoDB.Bson.BsonNull.Value + }; + var candidates = await DB.Default.GeoNear(new Coordinates2D(longitude, latitude), p => p.Distance, true, 50_000, default, default, query) + .Limit(200) + .ToListAsync(token); + var near = candidates.Where(p => p.Distance <= p.RangeKm * 1000).ToList(); + var hidden = await Domain.Relationships.Hidden.AuthorsHiddenFrom(viewer.Id, near.Select(p => p.ActorURI), forNotifications: false, token); + result.Data = near.Where(p => !hidden.Contains(p.ActorURI)).Select(p => + { + var view = ToView(p); + view.RangeKm = p.RangeKm; + view.DistanceKm = Math.Round(p.Distance.Value / 1000, 1); + return view; + }).ToList(); + return result; + } + async Task Answer(Func> action, string operation) { var result = new WebResult();