Streaming: what happens, told as it happens
Mastodon's streaming API: /api/v1/streaming as a WebSocket (streams
subscribed in the URL or by message) and /api/v1/streaming/{stream} as
server-sent events, with health and the URL advertised. The user stream
tells posts reaching the persona's home (not those an exclusive list keeps
apart, which its list stream tells), notifications, edits and deletions;
public, hashtag and list streams tell what belongs in them. An in-process
hub carries ids only; each connection maps a post or a notification for its
own persona as it sends it, so nothing it may not see, or whose author it
blocked or muted, goes out. A deletion reaches only the streams that showed
the post. The token comes as access_token, header or WebSocket protocol.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
This commit is contained in:
1 parent
b274ce1268
commit
9268968fd4
14 files changed
+650
-30
No files matched your search
@@ -40,6 +40,10 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
||||
|
||||
string Domain => new Uri(_localActors.BaseAddress).Authority;
|
||||
|
||||
// where clients open their streams (StreamingController): this server, as ws or wss
|
||||
string Streaming => new UriBuilder(_localActors.BaseAddress) { Scheme = _localActors.BaseAddress.StartsWith("https", StringComparison.OrdinalIgnoreCase) ? "wss" : "ws" }
|
||||
.Uri.GetLeftPart(UriPartial.Authority);
|
||||
|
||||
// every limit is what the server enforces, read from where it is enforced
|
||||
static object Statuses => new { max_characters = PrivaPub.Domain.Statuses.StatusService.MaxCharacters, max_media_attachments = 4, characters_reserved_per_url = 23 };
|
||||
|
||||
@@ -82,7 +86,7 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
||||
description = Description,
|
||||
email = string.Empty,
|
||||
version = Version,
|
||||
urls = new { },//no streaming API yet, so none is advertised
|
||||
urls = new { streaming_api = Streaming },
|
||||
stats = new { user_count = users, status_count = statuses, domain_count = domains },
|
||||
thumbnail = $"{_localActors.BaseAddress}/media/missing-header.png",
|
||||
languages = LanguageCodes(HttpContext.RequestServices),
|
||||
@@ -115,7 +119,7 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
||||
languages = LanguageCodes(HttpContext.RequestServices),
|
||||
configuration = new
|
||||
{
|
||||
urls = new { },//no streaming API yet, so none is advertised
|
||||
urls = new { streaming = Streaming },
|
||||
accounts = new { max_featured_tags = 0, max_pinned_statuses = StatusesController.MaxPins },
|
||||
statuses = Statuses,
|
||||
media_attachments = MediaAttachments,
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
using System.Net.WebSockets;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Api.Mastodon.Infrastructure;
|
||||
using PrivaPub.Api.Mastodon.Mappers;
|
||||
using PrivaPub.Domain.Privacy;
|
||||
using PrivaPub.Domain.Timelines;
|
||||
using PrivaPub.Models.Social;
|
||||
using PrivaPub.StaticServices;
|
||||
|
||||
namespace PrivaPub.Api.Mastodon.Controllers
|
||||
{
|
||||
// Mastodon's streaming API: one WebSocket on /api/v1/streaming that subscribes to streams by message, or one stream
|
||||
// per request as server-sent events on /api/v1/streaming/{stream}. Every stream needs a signed-in persona (the token
|
||||
// comes as access_token, in the Authorization header, or as the WebSocket's protocol). What a stream tells is mapped
|
||||
// for that persona as it is sent: a post it may not see, or whose author it blocked or muted, is never sent.
|
||||
public class StreamingController : MastodonController
|
||||
{
|
||||
static readonly TimeSpan KeepAlive = TimeSpan.FromSeconds(15);
|
||||
|
||||
readonly MastodonMapper _mapper;
|
||||
readonly DbEntities _dbEntities;
|
||||
|
||||
public StreamingController(MastodonMapper mapper, DbEntities dbEntities)
|
||||
{
|
||||
_mapper = mapper;
|
||||
_dbEntities = dbEntities;
|
||||
}
|
||||
|
||||
[HttpGet("/api/v1/streaming/health"), Microsoft.AspNetCore.Authorization.AllowAnonymous]
|
||||
public IActionResult Health() => Content("OK", "text/plain");
|
||||
|
||||
// the stream a name and its parameter make, or why not: a hashtag needs its tag, a list must be the persona's own
|
||||
async Task<(Streams.Key Key, string Error)> Resolve(string name, string tag, string list, CancellationToken token)
|
||||
{
|
||||
switch (name)
|
||||
{
|
||||
case Streams.User or Streams.UserNotification or Streams.Public or Streams.PublicLocal or Streams.PublicRemote:
|
||||
return (new Streams.Key(name), default);
|
||||
case Streams.Hashtag or Streams.HashtagLocal:
|
||||
var normal = TagsController.Normalise(tag);
|
||||
return normal.Length == 0 ? (default, "Missing tag name parameter") : (new Streams.Key(name, normal), default);
|
||||
case Streams.List:
|
||||
return !string.IsNullOrEmpty(list) && await DB.Default.Find<PersonaList>().Match(l => l.ID == list && l.AvatarId == MyId).ExecuteAnyAsync(token)
|
||||
? (new Streams.Key(name, list), default)
|
||||
: (default, "Unknown list");
|
||||
default:
|
||||
return (default, "Unknown stream type");
|
||||
}
|
||||
}
|
||||
|
||||
// what the event says, for this persona: a status or a notification as JSON, a deleted id as itself; null when it
|
||||
// is not the persona's to see
|
||||
async Task<string> Payload(Streams.Event e, CancellationToken token)
|
||||
{
|
||||
if (e.Name == "delete")
|
||||
return e.Id;
|
||||
if (e.Name == "notification")
|
||||
{
|
||||
var notification = await _dbEntities.Notifications.Match(n => n.ID == e.Id && n.AvatarId == MyId).ExecuteFirstAsync(token);
|
||||
var mapped = notification == default ? default : (await _mapper.Notifications(new List<Notification> { notification }, MyId, token)).FirstOrDefault();
|
||||
return mapped == default ? default : JsonSerializer.Serialize(mapped, MastodonJson.Options);
|
||||
}
|
||||
var post = await _dbEntities.Posts.MatchID(e.Id).ExecuteFirstAsync(token);
|
||||
if (post == default || !await VisibilityPolicy.CanSee(post, MyId, token))
|
||||
return default;
|
||||
var status = (await _mapper.Statuses(new[] { post }, MyId, token)).FirstOrDefault();
|
||||
return status == default ? default : JsonSerializer.Serialize(status, MastodonJson.Options);
|
||||
}
|
||||
|
||||
[HttpGet("/api/v1/streaming"), Scope("read")]
|
||||
public async Task<IActionResult> Socket(CancellationToken token)
|
||||
{
|
||||
if (!HttpContext.WebSockets.IsWebSocketRequest)
|
||||
return Error(StatusCodes.Status400BadRequest, "This is a WebSocket endpoint; server-sent events are at /api/v1/streaming/{stream}");
|
||||
var protocol = HttpContext.WebSockets.WebSocketRequestedProtocols.FirstOrDefault();
|
||||
using var socket = await HttpContext.WebSockets.AcceptWebSocketAsync(protocol);
|
||||
var listener = Streams.Open(MyId);
|
||||
var sending = new SemaphoreSlim(1, 1);
|
||||
async Task Send(JsonObject message)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(message.ToJsonString());
|
||||
await sending.WaitAsync(token);
|
||||
try
|
||||
{
|
||||
await socket.SendAsync(bytes, WebSocketMessageType.Text, true, token);
|
||||
}
|
||||
finally
|
||||
{
|
||||
sending.Release();
|
||||
}
|
||||
}
|
||||
async Task Subscribe(string type, string name, string tag, string list)
|
||||
{
|
||||
var (key, error) = await Resolve(name, tag, list, token);
|
||||
if (error != default)
|
||||
{
|
||||
await Send(new JsonObject { ["error"] = error, ["status"] = 400 });
|
||||
return;
|
||||
}
|
||||
if (type == "unsubscribe")
|
||||
listener.Stop(key);
|
||||
else
|
||||
listener.Listen(key);
|
||||
}
|
||||
try
|
||||
{
|
||||
if (Params.Get("stream") is { Length: > 0 } first)
|
||||
await Subscribe("subscribe", first, Params.Get("tag"), Params.Get("list"));
|
||||
using var done = CancellationTokenSource.CreateLinkedTokenSource(token);
|
||||
var reading = Task.Run(async () =>
|
||||
{
|
||||
var buffer = new byte[4096];
|
||||
while (socket.State == WebSocketState.Open && !done.IsCancellationRequested)
|
||||
{
|
||||
var text = new StringBuilder();
|
||||
WebSocketReceiveResult received;
|
||||
do
|
||||
{
|
||||
received = await socket.ReceiveAsync(buffer, done.Token);
|
||||
if (received.MessageType == WebSocketMessageType.Close)
|
||||
{
|
||||
done.Cancel();
|
||||
return;
|
||||
}
|
||||
text.Append(Encoding.UTF8.GetString(buffer, 0, received.Count));
|
||||
}
|
||||
while (!received.EndOfMessage && text.Length < 16384);
|
||||
JsonNode message;
|
||||
try
|
||||
{
|
||||
message = JsonNode.Parse(text.ToString());
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (message?["type"]?.GetValue<string>() is "subscribe" or "unsubscribe" && message["stream"]?.GetValue<string>() is { } stream)
|
||||
await Subscribe(message["type"]!.GetValue<string>(), stream, message["tag"]?.GetValue<string>(), message["list"]?.GetValue<string>());
|
||||
}
|
||||
}, done.Token);
|
||||
await foreach (var (key, e) in listener.Queue.Reader.ReadAllAsync(done.Token))
|
||||
{
|
||||
if (!listener.Hears(key) || await Payload(e, done.Token) is not { } payload)
|
||||
continue;
|
||||
await Send(new JsonObject { ["stream"] = new JsonArray(key.Wire.Select(w => (JsonNode)w).ToArray()), ["event"] = e.Name, ["payload"] = payload });
|
||||
}
|
||||
await reading;
|
||||
}
|
||||
catch (Exception ex) when (ex is OperationCanceledException or WebSocketException)
|
||||
{
|
||||
}
|
||||
finally
|
||||
{
|
||||
Streams.Close(listener);
|
||||
if (socket.State == WebSocketState.Open)
|
||||
await socket.CloseAsync(WebSocketCloseStatus.NormalClosure, default, CancellationToken.None);
|
||||
}
|
||||
return new EmptyResult();
|
||||
}
|
||||
|
||||
[HttpGet("/api/v1/streaming/{*stream}"), Scope("read")]
|
||||
public async Task<IActionResult> Events(string stream, CancellationToken token)
|
||||
{
|
||||
var name = stream.Replace('/', ':');
|
||||
var (key, error) = await Resolve(name, Params.Get("tag"), Params.Get("list"), token);
|
||||
if (error != default)
|
||||
return Error(StatusCodes.Status400BadRequest, error);
|
||||
Response.ContentType = "text/event-stream";
|
||||
Response.Headers.CacheControl = "no-cache";
|
||||
Response.Headers["X-Accel-Buffering"] = "no";
|
||||
var listener = Streams.Open(MyId);
|
||||
listener.Listen(key);
|
||||
var writing = new SemaphoreSlim(1, 1);
|
||||
async Task Write(string text)
|
||||
{
|
||||
await writing.WaitAsync(token);
|
||||
try
|
||||
{
|
||||
await Response.WriteAsync(text, token);
|
||||
await Response.Body.FlushAsync(token);
|
||||
}
|
||||
finally
|
||||
{
|
||||
writing.Release();
|
||||
}
|
||||
}
|
||||
try
|
||||
{
|
||||
await Write(":)\n\n");
|
||||
var beating = Task.Run(async () =>
|
||||
{
|
||||
while (!token.IsCancellationRequested)
|
||||
{
|
||||
await Task.Delay(KeepAlive, token);
|
||||
await Write(":thump\n\n");
|
||||
}
|
||||
}, token);
|
||||
await foreach (var (_, e) in listener.Queue.Reader.ReadAllAsync(token))
|
||||
{
|
||||
if (await Payload(e, token) is not { } payload)
|
||||
continue;
|
||||
await Write($"event: {e.Name}\ndata: {payload}\n\n");
|
||||
}
|
||||
await beating;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
}
|
||||
finally
|
||||
{
|
||||
Streams.Close(listener);
|
||||
}
|
||||
return new EmptyResult();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -160,7 +160,7 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
||||
|
||||
public class NotificationsController : MastodonController
|
||||
{
|
||||
static readonly Dictionary<NotificationType, string> Names = new()
|
||||
internal static readonly Dictionary<NotificationType, string> Names = new()
|
||||
{
|
||||
[NotificationType.Mention] = "mention",
|
||||
[NotificationType.Follow] = "follow",
|
||||
@@ -351,29 +351,8 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
||||
return Json(new { count = (await Map(unread, token)).Count });
|
||||
}
|
||||
|
||||
async Task<List<Entities.Notification>> Map(List<NotificationEntity> notifications, CancellationToken token)
|
||||
{
|
||||
var accounts = await _mapper.Accounts(notifications.Select(n => n.FromAccountId), token);
|
||||
var postIds = notifications.Where(n => n.PostId != default).Select(n => n.PostId).Distinct().ToList();
|
||||
var posts = postIds.Count == 0
|
||||
? new List<PostEntity>()
|
||||
: await _dbEntities.Posts.Match(p => postIds.Contains(p.ID)).Match(VisibilityPolicy.IsShown).ExecuteAsync(token);
|
||||
var statuses = (await _mapper.Statuses(posts, MyId, token)).ToDictionary(s => s.Id);
|
||||
return notifications
|
||||
.Where(n => accounts.ContainsKey(n.FromAccountId ?? string.Empty) && (n.PostId == default || statuses.ContainsKey(n.PostId)))
|
||||
.Select(n => new Entities.Notification
|
||||
{
|
||||
Id = n.ID,
|
||||
Type = Names[n.Type],
|
||||
CreatedAt = MastodonJson.Time(n.CreatedAt),
|
||||
GroupKey = $"ungrouped-{n.ID}",
|
||||
Account = accounts[n.FromAccountId],
|
||||
Status = n.PostId == default ? default : statuses[n.PostId],
|
||||
Emoji = n.Emoji == default ? default : n.EmojiURL == default ? n.Emoji : $":{n.Emoji}:",
|
||||
EmojiUrl = _mapper.ProxiedUrl(n.EmojiURL)
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
Task<List<Entities.Notification>> Map(List<NotificationEntity> notifications, CancellationToken token) =>
|
||||
_mapper.Notifications(notifications, MyId, token);
|
||||
|
||||
static NotificationType? Parse(string name) => Names.FirstOrDefault(n => n.Value == name) is { Value: not null } pair ? pair.Key : (NotificationType?)null;
|
||||
}
|
||||
|
||||
@@ -40,6 +40,31 @@ namespace PrivaPub.Api.Mastodon.Mappers
|
||||
|
||||
public string ProxiedUrl(string url) => Proxied(url);
|
||||
|
||||
// notifications as the persona sees them: one whose account or post it can no longer see is left out
|
||||
public async Task<List<Entities.Notification>> Notifications(List<Models.Social.Notification> notifications, string viewerId, CancellationToken token)
|
||||
{
|
||||
var accounts = await Accounts(notifications.Select(n => n.FromAccountId), token);
|
||||
var postIds = notifications.Where(n => n.PostId != default).Select(n => n.PostId).Distinct().ToList();
|
||||
var posts = postIds.Count == 0
|
||||
? new List<PostEntity>()
|
||||
: await _dbEntities.Posts.Match(p => postIds.Contains(p.ID)).Match(VisibilityPolicy.IsShown).ExecuteAsync(token);
|
||||
var statuses = (await Statuses(posts, viewerId, token)).ToDictionary(s => s.Id);
|
||||
return notifications
|
||||
.Where(n => accounts.ContainsKey(n.FromAccountId ?? string.Empty) && (n.PostId == default || statuses.ContainsKey(n.PostId)))
|
||||
.Select(n => new Entities.Notification
|
||||
{
|
||||
Id = n.ID,
|
||||
Type = Controllers.NotificationsController.Names[n.Type],
|
||||
CreatedAt = MastodonJson.Time(n.CreatedAt),
|
||||
GroupKey = $"ungrouped-{n.ID}",
|
||||
Account = accounts[n.FromAccountId],
|
||||
Status = n.PostId == default ? default : statuses[n.PostId],
|
||||
Emoji = n.Emoji == default ? default : n.EmojiURL == default ? n.Emoji : $":{n.Emoji}:",
|
||||
EmojiUrl = Proxied(n.EmojiURL)
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
|
||||
string MissingAvatar => $"{_localActors.BaseAddress}/media/missing-avatar.png";
|
||||
string MissingHeader => $"{_localActors.BaseAddress}/media/missing-header.png";
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ namespace PrivaPub.Domain.Social
|
||||
return;
|
||||
try
|
||||
{
|
||||
await DB.Default.SaveAsync(new Notification
|
||||
var notification = new Notification
|
||||
{
|
||||
AvatarId = avatarId,
|
||||
Type = type,
|
||||
@@ -26,7 +26,11 @@ namespace PrivaPub.Domain.Social
|
||||
Emoji = emoji,
|
||||
EmojiURL = emojiUrl,
|
||||
DedupeKey = emoji == default ? $"{type}|{avatarId}|{fromActorUri}|{postId}" : $"{type}|{avatarId}|{fromActorUri}|{postId}|{emoji}"
|
||||
}, token);
|
||||
};
|
||||
await DB.Default.SaveAsync(notification, token);
|
||||
var told = new Timelines.Streams.Event("notification", notification.ID);
|
||||
Timelines.Streams.ToPersona(avatarId, new Timelines.Streams.Key(Timelines.Streams.User), told);
|
||||
Timelines.Streams.ToPersona(avatarId, new Timelines.Streams.Key(Timelines.Streams.UserNotification), told);
|
||||
}
|
||||
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
|
||||
{
|
||||
|
||||
@@ -310,6 +310,7 @@ namespace PrivaPub.Domain.Statuses
|
||||
post.EditedAt = DateTime.UtcNow;
|
||||
post.UpdateDate = post.EditedAt;
|
||||
await DB.Default.SaveAsync(post, token);
|
||||
await Fanout.Edited(post, token);
|
||||
|
||||
if (!post.IsLocalOnly)
|
||||
{
|
||||
@@ -354,6 +355,7 @@ namespace PrivaPub.Domain.Statuses
|
||||
.Modify(p => p.Media, new List<PostMedia>())
|
||||
.Modify(p => p.Revisions, new List<PostRevision>())
|
||||
.ExecuteAsync(token);
|
||||
await Fanout.Deleting(post, token);
|
||||
await DB.Default.DeleteAsync<TimelineEntry>(e => e.PostId == post.ID || e.ReblogOfPostId == post.ID);
|
||||
// boosts of it end with it, as on Mastodon, so no count keeps them
|
||||
await DB.Default.Update<PostEntity>().Match(p => p.ReblogOfPostId == post.ID && !p.DeletedAt.HasValue)
|
||||
@@ -493,6 +495,7 @@ namespace PrivaPub.Domain.Statuses
|
||||
|
||||
if (existing == default)
|
||||
return new StatusOutcome(original);
|
||||
await Fanout.Deleting(existing, token);
|
||||
await DB.Default.DeleteAsync<PostEntity>(existing.ID);
|
||||
await DB.Default.DeleteAsync<TimelineEntry>(e => e.PostId == existing.ID);
|
||||
await DB.Default.Update<PostEntity>().MatchID(original.ID).Modify(b => b.Inc(p => p.ReblogsCount, -1)).ExecuteAsync(token);
|
||||
|
||||
@@ -85,6 +85,90 @@ namespace PrivaPub.Domain.Timelines
|
||||
if (string.IsNullOrEmpty(post.ReblogOfPostId))
|
||||
foreach (var mention in post.Mentions.Where(m => m.IsLocal && !string.IsNullOrEmpty(m.AccountId)))
|
||||
await Notifications.Add(mention.AccountId, NotificationType.Mention, post.AuthorAccountId ?? post.GroupUserId, post.ActorURI, post.ID, token);
|
||||
if (Streams.Anyone)
|
||||
await Stream(post, recipients, token);
|
||||
}
|
||||
|
||||
// the post to the streams it belongs in: the homes it reached (but not where an exclusive list keeps its author
|
||||
// apart), the lists of those homes that hold its author, and the public feeds and hashtags when it is public
|
||||
static async Task Stream(Post post, IReadOnlyCollection<string> recipients, CancellationToken token)
|
||||
{
|
||||
var update = new Streams.Event("update", post.ID);
|
||||
var author = post.AuthorAccountId ?? post.GroupUserId;
|
||||
var listening = recipients.Where(Streams.Listening).ToList();
|
||||
if (listening.Count > 0)
|
||||
{
|
||||
var memberships = await DB.Default.Find<PersonaListMember>().Match(m => listening.Contains(m.AvatarId) && m.AccountId == author).ExecuteAsync(token);
|
||||
var listIds = memberships.Select(m => m.ListId).Distinct().ToList();
|
||||
var exclusive = listIds.Count == 0
|
||||
? new HashSet<string>()
|
||||
: (await DB.Default.Find<PersonaList>().Match(l => listIds.Contains(l.ID) && l.Exclusive).ExecuteAsync(token)).Select(l => l.ID).ToHashSet();
|
||||
foreach (var avatarId in listening)
|
||||
{
|
||||
var lists = memberships.Where(m => m.AvatarId == avatarId).Select(m => m.ListId).ToList();
|
||||
if (!lists.Any(exclusive.Contains))
|
||||
Streams.ToPersona(avatarId, new Streams.Key(Streams.User), update);
|
||||
foreach (var listId in lists)
|
||||
Streams.ToPersona(avatarId, new Streams.Key(Streams.List, listId), update);
|
||||
}
|
||||
}
|
||||
if (post.Visibility != PostVisibility.Public || !string.IsNullOrEmpty(post.ReblogOfPostId) || !Privacy.VisibilityPolicy.Shown(post))
|
||||
return;
|
||||
var local = !post.IsFederatedCopy;
|
||||
if (!post.IsLocalOnly)
|
||||
Streams.ToAll(new Streams.Key(Streams.Public), update);
|
||||
Streams.ToAll(new Streams.Key(local ? Streams.PublicLocal : Streams.PublicRemote), update);
|
||||
foreach (var tag in post.Tags.Distinct())
|
||||
{
|
||||
if (!post.IsLocalOnly)
|
||||
Streams.ToAll(new Streams.Key(Streams.Hashtag, tag), update);
|
||||
if (local)
|
||||
Streams.ToAll(new Streams.Key(Streams.HashtagLocal, tag), update);
|
||||
}
|
||||
}
|
||||
|
||||
// a post's deletion, told before its home entries go: to the homes holding it or a boost of it (the boost's id
|
||||
// there), to its author's own connections, and to the public feeds and hashtags when it was public. Nobody else
|
||||
// hears of it, not even its id.
|
||||
public static async Task Deleting(Post post, CancellationToken token)
|
||||
{
|
||||
if (!Streams.Anyone || post == default)
|
||||
return;
|
||||
var entries = await DB.Default.Find<TimelineEntry>().Match(e => e.PostId == post.ID || e.ReblogOfPostId == post.ID).ExecuteAsync(token);
|
||||
foreach (var entry in entries.Where(e => Streams.Listening(e.AvatarId)))
|
||||
Streams.ToPersonaEverywhere(entry.AvatarId, new Streams.Event("delete", entry.PostId));
|
||||
var author = post.AuthorAccountId ?? post.GroupUserId;
|
||||
if (!post.IsFederatedCopy && !string.IsNullOrEmpty(author))
|
||||
Streams.ToPersonaEverywhere(author, new Streams.Event("delete", post.ID));
|
||||
if (post.Visibility != PostVisibility.Public || !string.IsNullOrEmpty(post.ReblogOfPostId))
|
||||
return;
|
||||
var gone = new Streams.Event("delete", post.ID);
|
||||
var local = !post.IsFederatedCopy;
|
||||
Streams.ToAll(new Streams.Key(Streams.Public), gone);
|
||||
Streams.ToAll(new Streams.Key(local ? Streams.PublicLocal : Streams.PublicRemote), gone);
|
||||
foreach (var tag in post.Tags.Distinct())
|
||||
{
|
||||
Streams.ToAll(new Streams.Key(Streams.Hashtag, tag), gone);
|
||||
if (local)
|
||||
Streams.ToAll(new Streams.Key(Streams.HashtagLocal, tag), gone);
|
||||
}
|
||||
}
|
||||
|
||||
// an edit to the homes holding the post and, when it is public, to the public feeds (status.update)
|
||||
public static async Task Edited(Post post, CancellationToken token)
|
||||
{
|
||||
if (!Streams.Anyone)
|
||||
return;
|
||||
var update = new Streams.Event("status.update", post.ID);
|
||||
var homes = (await DB.Default.Find<TimelineEntry>().Match(e => e.PostId == post.ID).ExecuteAsync(token)).Select(e => e.AvatarId).Distinct();
|
||||
foreach (var avatarId in homes.Where(Streams.Listening))
|
||||
Streams.ToPersona(avatarId, new Streams.Key(Streams.User), update);
|
||||
if (post.Visibility == PostVisibility.Public && Privacy.VisibilityPolicy.Shown(post))
|
||||
{
|
||||
if (!post.IsLocalOnly)
|
||||
Streams.ToAll(new Streams.Key(Streams.Public), update);
|
||||
Streams.ToAll(new Streams.Key(post.IsFederatedCopy ? Streams.PublicRemote : Streams.PublicLocal), update);
|
||||
}
|
||||
}
|
||||
|
||||
async Task<bool> Shows(Following following, Post post, CancellationToken token)
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Threading.Channels;
|
||||
|
||||
namespace PrivaPub.Domain.Timelines
|
||||
{
|
||||
// Mastodon's streaming, inside this one process: what happens (a post arriving in someone's home or a public feed, a
|
||||
// notification, an edit, a deletion) is told to the connections listening for it, by id only. Each connection maps a
|
||||
// post or a notification for its own persona when it sends it (StreamingController), so blocks, mutes and visibility
|
||||
// are decided there, never here. Nothing is kept: a connection that is gone misses what happened meanwhile, and a
|
||||
// client reads the timeline again when it comes back.
|
||||
public static class Streams
|
||||
{
|
||||
public const string User = "user";
|
||||
public const string UserNotification = "user:notification";
|
||||
public const string Public = "public";
|
||||
public const string PublicLocal = "public:local";
|
||||
public const string PublicRemote = "public:remote";
|
||||
public const string Hashtag = "hashtag";
|
||||
public const string HashtagLocal = "hashtag:local";
|
||||
public const string List = "list";
|
||||
public const string Direct = "direct";
|
||||
|
||||
public static readonly string[] Names = { User, UserNotification, Public, PublicLocal, PublicRemote, Hashtag, HashtagLocal, List, Direct };
|
||||
|
||||
// what happened: Mastodon's event name, and the id of what it is about
|
||||
public sealed record Event(string Name, string Id);
|
||||
|
||||
// one stream a connection listens to: its name and, for a hashtag or a list, which one
|
||||
public sealed record Key(string Name, string Parameter = default)
|
||||
{
|
||||
public string[] Wire => Parameter == default ? new[] { Name } : new[] { Name, Parameter };
|
||||
}
|
||||
|
||||
public sealed class Listener
|
||||
{
|
||||
readonly ConcurrentDictionary<Key, byte> _keys = new();
|
||||
|
||||
public Listener(string avatarId)
|
||||
{
|
||||
AvatarId = avatarId;
|
||||
}
|
||||
|
||||
public string AvatarId { get; }
|
||||
|
||||
// what reached it and was not sent yet; the oldest goes first when a slow connection falls behind
|
||||
public Channel<(Key Key, Event Event)> Queue { get; } =
|
||||
Channel.CreateBounded<(Key, Event)>(new BoundedChannelOptions(1000) { FullMode = BoundedChannelFullMode.DropOldest, SingleReader = true });
|
||||
|
||||
public void Listen(Key key) => _keys[key] = 0;
|
||||
|
||||
public void Stop(Key key) => _keys.TryRemove(key, out _);
|
||||
|
||||
public bool Hears(Key key) => _keys.ContainsKey(key);
|
||||
|
||||
public IEnumerable<Key> Keys => _keys.Keys;
|
||||
|
||||
public void Tell(Key key, Event e) => Queue.Writer.TryWrite((key, e));
|
||||
}
|
||||
|
||||
static readonly ConcurrentDictionary<Listener, byte> Listeners = new();
|
||||
|
||||
public static Listener Open(string avatarId)
|
||||
{
|
||||
var listener = new Listener(avatarId);
|
||||
Listeners[listener] = 0;
|
||||
return listener;
|
||||
}
|
||||
|
||||
public static void Close(Listener listener)
|
||||
{
|
||||
Listeners.TryRemove(listener, out _);
|
||||
listener.Queue.Writer.TryComplete();
|
||||
}
|
||||
|
||||
public static int Count => Listeners.Count;
|
||||
|
||||
// whether the persona has a connection open, so the work of finding its lists can be skipped when it has none
|
||||
public static bool Listening(string avatarId) => Listeners.Keys.Any(l => l.AvatarId == avatarId);
|
||||
|
||||
// to the persona's own streams: its home (user) and, for a notification, user:notification too
|
||||
public static void ToPersona(string avatarId, Key key, Event e)
|
||||
{
|
||||
foreach (var listener in Listeners.Keys)
|
||||
if (listener.AvatarId == avatarId && listener.Hears(key))
|
||||
listener.Tell(key, e);
|
||||
}
|
||||
|
||||
// to everyone listening to a shared stream (public, a hashtag)
|
||||
public static void ToAll(Key key, Event e)
|
||||
{
|
||||
foreach (var listener in Listeners.Keys)
|
||||
if (listener.Hears(key))
|
||||
listener.Tell(key, e);
|
||||
}
|
||||
|
||||
// to every stream the persona listens to (a deletion: wherever its client may show the post)
|
||||
public static void ToPersonaEverywhere(string avatarId, Event e)
|
||||
{
|
||||
foreach (var listener in Listeners.Keys)
|
||||
if (listener.AvatarId == avatarId)
|
||||
foreach (var key in listener.Keys)
|
||||
listener.Tell(key, e);
|
||||
}
|
||||
|
||||
public static bool Anyone => !Listeners.IsEmpty;
|
||||
}
|
||||
}
|
||||
@@ -126,6 +126,7 @@ namespace PrivaPub.Federation.Inbox.Handlers
|
||||
.ExecuteFirstAsync(token);
|
||||
if (reblog == default)
|
||||
return false;
|
||||
await Domain.Timelines.Fanout.Deleting(reblog, token);
|
||||
await DB.Default.DeleteAsync<PostEntity>(reblog.ID);
|
||||
await DB.Default.DeleteAsync<TimelineEntry>(e => e.PostId == reblog.ID);
|
||||
await DB.Default.Update<PostEntity>().MatchID(reblog.ReblogOfPostId).Modify(b => b.Inc(p => p.ReblogsCount, -1)).ExecuteAsync(token);
|
||||
|
||||
@@ -73,6 +73,7 @@ namespace PrivaPub.Federation.Inbox
|
||||
post.EditedAt = note.Updated ?? DateTime.UtcNow;
|
||||
post.UpdateDate = DateTime.UtcNow;
|
||||
await DB.Default.SaveAsync(post, token);
|
||||
await Domain.Timelines.Fanout.Edited(post, token);
|
||||
await records.Revise(note, activityId, token);
|
||||
await Requote(post, note, quotes, token);
|
||||
return true;
|
||||
@@ -97,6 +98,7 @@ namespace PrivaPub.Federation.Inbox
|
||||
public static async Task Remove(PostEntity post, string objectUri, CancellationToken token)
|
||||
{
|
||||
await Tombstone(objectUri, token);
|
||||
await Domain.Timelines.Fanout.Deleting(post, token);
|
||||
await DB.Default.DeleteAsync<PostEntity>(post.ID);
|
||||
await DB.Default.DeleteAsync<ObjectRecord>(r => r.PostId == post.ID || r.ObjectURI == objectUri);
|
||||
await DB.Default.DeleteAsync<TimelineEntry>(e => e.PostId == post.ID || e.ReblogOfPostId == post.ID);
|
||||
|
||||
@@ -154,10 +154,25 @@ try
|
||||
|
||||
app.UseRequestLocalization(await localizationService.Get());
|
||||
|
||||
app.UseWebSockets(new WebSocketOptions { KeepAliveInterval = TimeSpan.FromSeconds(30) });
|
||||
app.UseRouting();
|
||||
app.UseTrafficMeter();
|
||||
app.UseRateLimiter();
|
||||
|
||||
// a stream's token may come as access_token or as the WebSocket's protocol, as Mastodon's clients send it
|
||||
app.Use(async (context, next) =>
|
||||
{
|
||||
if (context.Request.Path.StartsWithSegments("/api/v1/streaming") && !context.Request.Headers.ContainsKey("Authorization"))
|
||||
{
|
||||
var token = context.Request.Query["access_token"].ToString();
|
||||
if (string.IsNullOrEmpty(token))
|
||||
token = context.Request.Headers["Sec-WebSocket-Protocol"].ToString();
|
||||
if (!string.IsNullOrEmpty(token))
|
||||
context.Request.Headers.Authorization = "Bearer " + token;
|
||||
}
|
||||
await next();
|
||||
});
|
||||
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
//app.UseWhen(context => context.Request.Path.StartsWithSegments("/peasants") ||
|
||||
|
||||
Reference in new issue
Block a user