Located posts: local only, rounded, matched against their own radius
A post given a latitude and longitude becomes LocalGeo: its position is rounded to two decimals (about a kilometre) and stored as a 2dsphere point, its radius clamped to 1-50 km, and it is local only - no Create, no delivery, no outbox, never in the Mastodon API or on a profile page. /clientapi/post/nearby takes the viewer's position for the query only (it is neither stored nor logged), runs $geoNear and keeps posts within each post's own radius, minus authors the viewer blocks or mutes. A located post cannot be direct or in a group. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
This commit is contained in:
1 parent
28b581b6b1
commit
c00865d805
8 files changed
+150
-3
No files matched your search
@@ -24,6 +24,15 @@ namespace PrivaPub.ClientModels.Post
|
|||||||
public string SpoilerText { get; set; }
|
public string SpoilerText { get; set; }
|
||||||
|
|
||||||
public string Visibility { get; set; }//public (default), unlisted, followersonly
|
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
|
public class UpdatePostForm
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ namespace PrivaPub.ClientModels.Post
|
|||||||
public bool IsFederatedCopy { get; set; }
|
public bool IsFederatedCopy { get; set; }
|
||||||
public DateTime CreationDate { get; set; }
|
public DateTime CreationDate { get; set; }
|
||||||
public ViewPost ReblogOf { get; set; }
|
public ViewPost ReblogOf { get; set; }
|
||||||
|
public double? RangeKm { get; set; }
|
||||||
|
public double? DistanceKm { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class ViewDmGroup
|
public class ViewDmGroup
|
||||||
|
|||||||
@@ -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<Post>().OneAsync(((ViewPost)created.Data).Id, token);
|
||||||
|
|
||||||
|
var three = (List<ViewPost>)(await _harness.Posts.Nearby(bobRoot, bob.Id, latitude + 0.027, longitude, token)).Data;
|
||||||
|
var eight = (List<ViewPost>)(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<Job>().Match(j => j.Kind == JobKind.Deliver).ExecuteAsync(token))
|
||||||
|
.Select(j => JsonSerializer.Deserialize<DeliveryPayload>(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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,6 +32,10 @@ namespace PrivaPub.Controllers.ClientToServer
|
|||||||
public async Task<IActionResult> Insert(InsertPostForm form, CancellationToken token) =>
|
public async Task<IActionResult> Insert(InsertPostForm form, CancellationToken token) =>
|
||||||
!ModelState.IsValid ? Invalid() : Answer(await _postsService.InsertPost(User.GetUserId(), form, token));
|
!ModelState.IsValid ? Invalid() : Answer(await _postsService.InsertPost(User.GetUserId(), form, token));
|
||||||
|
|
||||||
|
[HttpGet, Route("/clientapi/post/nearby")]
|
||||||
|
public async Task<IActionResult> 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")]
|
[HttpPost, Route("/clientapi/post/update")]
|
||||||
public async Task<IActionResult> Update(UpdatePostForm form, CancellationToken token) =>
|
public async Task<IActionResult> Update(UpdatePostForm form, CancellationToken token) =>
|
||||||
!ModelState.IsValid ? Invalid() : Answer(await _postsService.UpdatePost(User.GetUserId(), form, token));
|
!ModelState.IsValid ? Invalid() : Answer(await _postsService.UpdatePost(User.GetUserId(), form, token));
|
||||||
|
|||||||
@@ -36,6 +36,9 @@ namespace PrivaPub.Domain.Statuses
|
|||||||
public string ConversationId { get; init; }
|
public string ConversationId { get; init; }
|
||||||
public IReadOnlyList<string> Recipients { get; init; } = Array.Empty<string>();
|
public IReadOnlyList<string> Recipients { get; init; } = Array.Empty<string>();
|
||||||
public IReadOnlyList<string> MediaIds { get; init; }
|
public IReadOnlyList<string> 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)
|
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))
|
if (parent != default && !await VisibilityPolicy.CanSee(parent, author.Id, token))
|
||||||
return StatusOutcome.Fail(StatusCodes.Status404NotFound, "Record not found");
|
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 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 isLocalOnly = group is { IsFederated: false } || located;
|
||||||
var visibility = isLocalOnly ? PostVisibility.Circle : draft.Visibility is PostVisibility.Circle or PostVisibility.LocalGeo ? PostVisibility.Public : draft.Visibility;
|
var visibility = located ? PostVisibility.LocalGeo
|
||||||
|
: isLocalOnly ? PostVisibility.Circle
|
||||||
|
: draft.Visibility is PostVisibility.Circle or PostVisibility.LocalGeo ? PostVisibility.Public : draft.Visibility;
|
||||||
var post = new PostEntity
|
var post = new PostEntity
|
||||||
{
|
{
|
||||||
GroupUserId = author.Id,
|
GroupUserId = author.Id,
|
||||||
@@ -130,6 +140,11 @@ namespace PrivaPub.Domain.Statuses
|
|||||||
post.ID = (string)post.GenerateNewID();
|
post.ID = (string)post.GenerateNewID();
|
||||||
post.ObjectURI = author.PostUri(post.ID);
|
post.ObjectURI = author.PostUri(post.ID);
|
||||||
post.Url = author.PostHtmlUrl(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;
|
JsonObject create = default;
|
||||||
if (visibility == PostVisibility.Direct)
|
if (visibility == PostVisibility.Direct)
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ namespace PrivaPub.Infrastructure.Data
|
|||||||
await Plain<Post>(token, p => p.ActorURI);
|
await Plain<Post>(token, p => p.ActorURI);
|
||||||
await Plain<Post>(token, p => p.ConversationId, p => p.ID);
|
await Plain<Post>(token, p => p.ConversationId, p => p.ID);
|
||||||
await Plain<Post>(token, p => p.InReplyToURI);
|
await Plain<Post>(token, p => p.InReplyToURI);
|
||||||
|
await DB.Default.Index<Post>().Key(p => p.Geo, KeyType.Geo2DSphere).CreateAsync(token);
|
||||||
|
|
||||||
await Unique<DmPost>(p => p.ObjectURI, Builders<DmPost>.Filter.Type(p => p.ObjectURI, BsonType.String), token);
|
await Unique<DmPost>(p => p.ObjectURI, Builders<DmPost>.Filter.Type(p => p.ObjectURI, BsonType.String), token);
|
||||||
await Plain<DmPost>(token, p => p.GroupId, p => p.ID);
|
await Plain<DmPost>(token, p => p.GroupId, p => p.ID);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using MongoDB.Bson.Serialization.Attributes;
|
||||||
using MongoDB.Entities;
|
using MongoDB.Entities;
|
||||||
|
|
||||||
namespace PrivaPub.Models.Post
|
namespace PrivaPub.Models.Post
|
||||||
@@ -24,6 +25,10 @@ namespace PrivaPub.Models.Post
|
|||||||
public List<PostMedia> Media { get; set; } = new();
|
public List<PostMedia> Media { get; set; } = new();
|
||||||
public List<float> Location { get; set; } = new();
|
public List<float> Location { get; set; } = new();
|
||||||
public float RangeKm { get; set; } = 5.0f;
|
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 HasContentWarning { get; set; } = false;
|
||||||
public bool IsLocalOnly { get; set; }
|
public bool IsLocalOnly { get; set; }
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using Microsoft.Extensions.Localization;
|
using Microsoft.Extensions.Localization;
|
||||||
|
|
||||||
|
using MongoDB.Driver;
|
||||||
using MongoDB.Entities;
|
using MongoDB.Entities;
|
||||||
|
|
||||||
using PrivaPub.ClientModels;
|
using PrivaPub.ClientModels;
|
||||||
@@ -27,6 +28,7 @@ namespace PrivaPub.Services
|
|||||||
Task<WebResult> InsertPost(string rootUserId, InsertPostForm form, CancellationToken token);
|
Task<WebResult> InsertPost(string rootUserId, InsertPostForm form, CancellationToken token);
|
||||||
Task<WebResult> DeletePost(string rootUserId, DeletePostForm form, CancellationToken token);
|
Task<WebResult> DeletePost(string rootUserId, DeletePostForm form, CancellationToken token);
|
||||||
Task<WebResult> UpdatePost(string rootUserId, UpdatePostForm form, CancellationToken token);
|
Task<WebResult> UpdatePost(string rootUserId, UpdatePostForm form, CancellationToken token);
|
||||||
|
Task<WebResult> Nearby(string rootUserId, string avatarId, double latitude, double longitude, CancellationToken token);
|
||||||
Task<WebResult> GetPosts(string rootUserId, string avatarId, string groupId, CancellationToken token);
|
Task<WebResult> GetPosts(string rootUserId, string avatarId, string groupId, CancellationToken token);
|
||||||
Task<WebResult> InsertDm(string rootUserId, InsertDmForm form, CancellationToken token);
|
Task<WebResult> InsertDm(string rootUserId, InsertDmForm form, CancellationToken token);
|
||||||
Task<WebResult> GetDms(string rootUserId, string avatarId, string dmGroupId, CancellationToken token);
|
Task<WebResult> GetDms(string rootUserId, string avatarId, string dmGroupId, CancellationToken token);
|
||||||
@@ -68,7 +70,10 @@ namespace PrivaPub.Services
|
|||||||
Sensitive = form.HasContentWarning,
|
Sensitive = form.HasContentWarning,
|
||||||
Visibility = LocalVisibility(form.Visibility),
|
Visibility = LocalVisibility(form.Visibility),
|
||||||
InReplyTo = form.AnsweringToPostId,
|
InReplyTo = form.AnsweringToPostId,
|
||||||
GroupId = form.GroupId
|
GroupId = form.GroupId,
|
||||||
|
Latitude = form.Latitude,
|
||||||
|
Longitude = form.Longitude,
|
||||||
|
RangeKm = form.RangeKm
|
||||||
}, token), nameof(InsertPost));
|
}, token), nameof(InsertPost));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,6 +198,35 @@ namespace PrivaPub.Services
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
public async Task<WebResult> 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<PostEntity>(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<WebResult> Answer(Func<Task<StatusOutcome>> action, string operation)
|
async Task<WebResult> Answer(Func<Task<StatusOutcome>> action, string operation)
|
||||||
{
|
{
|
||||||
var result = new WebResult();
|
var result = new WebResult();
|
||||||
|
|||||||
Reference in new issue
Block a user