[backend/api-shared] Clone NoteResponse / StatusEntity in streaming handlers' EnforceRenoteReplyVisibility functions (ISH-250)

This commit is contained in:
Laura Hausmann 2024-04-08 21:36:12 +02:00
parent bc50aa0259
commit 998a4412cb
No known key found for this signature in database
GPG key ID: D044E84C5BE01605
5 changed files with 152 additions and 82 deletions

View file

@ -7,7 +7,7 @@ using JI = System.Text.Json.Serialization.JsonIgnoreAttribute;
namespace Iceshrimp.Backend.Controllers.Mastodon.Schemas.Entities; namespace Iceshrimp.Backend.Controllers.Mastodon.Schemas.Entities;
public class StatusEntity : IEntity public class StatusEntity : IEntity, ICloneable
{ {
[J("content")] public required string? Content { get; set; } [J("content")] public required string? Content { get; set; }
[J("uri")] public required string Uri { get; set; } [J("uri")] public required string Uri { get; set; }
@ -79,6 +79,8 @@ public class StatusEntity : IEntity
_ => throw GracefulException.BadRequest($"Unknown visibility: {visibility}") _ => throw GracefulException.BadRequest($"Unknown visibility: {visibility}")
}; };
} }
public object Clone() => MemberwiseClone();
} }
public class StatusContext public class StatusContext

View file

@ -1,5 +1,6 @@
using System.Text.Json; using System.Text.Json;
using Iceshrimp.Backend.Controllers.Mastodon.Renderers; using Iceshrimp.Backend.Controllers.Mastodon.Renderers;
using Iceshrimp.Backend.Controllers.Mastodon.Schemas.Entities;
using Iceshrimp.Backend.Core.Database.Tables; using Iceshrimp.Backend.Core.Database.Tables;
namespace Iceshrimp.Backend.Controllers.Mastodon.Streaming.Channels; namespace Iceshrimp.Backend.Controllers.Mastodon.Streaming.Channels;
@ -12,7 +13,7 @@ public class PublicChannel(
bool onlyMedia bool onlyMedia
) : IChannel ) : IChannel
{ {
public readonly ILogger<PublicChannel> Logger = private readonly ILogger<PublicChannel> _logger =
connection.Scope.ServiceProvider.GetRequiredService<ILogger<PublicChannel>>(); connection.Scope.ServiceProvider.GetRequiredService<ILogger<PublicChannel>>();
public string Name => name; public string Name => name;
@ -45,23 +46,44 @@ public class PublicChannel(
connection.EventService.NoteDeleted -= OnNoteDeleted; connection.EventService.NoteDeleted -= OnNoteDeleted;
} }
private bool IsApplicable(Note note) private NoteWithVisibilities? IsApplicable(Note note)
{
if (!IsApplicableBool(note)) return null;
var res = EnforceRenoteReplyVisibility(note);
return res is not { Note.IsPureRenote: true, Renote: null } ? null : res;
}
private bool IsApplicableBool(Note note)
{ {
if (note.Visibility != Note.NoteVisibility.Public) return false; if (note.Visibility != Note.NoteVisibility.Public) return false;
if (!local && note.UserHost == null) return false; if (!local && note.UserHost == null) return false;
if (!remote && note.UserHost != null) return false; if (!remote && note.UserHost != null) return false;
if (onlyMedia && note.FileIds.Count == 0) return false; return !onlyMedia || note.FileIds.Count != 0;
return EnforceRenoteReplyVisibility(note) is not { IsPureRenote: true, Renote: null };
} }
private Note EnforceRenoteReplyVisibility(Note note) private NoteWithVisibilities EnforceRenoteReplyVisibility(Note note)
{ {
if (note.Renote?.IsVisibleFor(connection.Token.User, connection.Following) ?? false) var wrapped = new NoteWithVisibilities(note);
note.Renote = null; if (wrapped.Renote?.IsVisibleFor(connection.Token.User, connection.Following) ?? false)
if (note.Reply?.IsVisibleFor(connection.Token.User, connection.Following) ?? false) wrapped.Renote = null;
note.Reply = null;
return note; return wrapped;
}
private class NoteWithVisibilities(Note note)
{
public readonly Note Note = note;
public Note? Renote = note.Renote;
}
private static StatusEntity EnforceRenoteReplyVisibility(StatusEntity rendered, NoteWithVisibilities note)
{
var renote = note.Renote == null && rendered.Renote != null;
if (!renote) return rendered;
rendered = (StatusEntity)rendered.Clone();
if (renote) rendered.Renote = null;
return rendered;
} }
private bool IsFiltered(Note note) => connection.IsFiltered(note.User) || private bool IsFiltered(Note note) => connection.IsFiltered(note.User) ||
@ -73,13 +95,14 @@ public class PublicChannel(
{ {
try try
{ {
if (!IsApplicable(note)) return; var wrapped = IsApplicable(note);
if (wrapped == null) return;
if (IsFiltered(note)) return; if (IsFiltered(note)) return;
await using var scope = connection.ScopeFactory.CreateAsyncScope(); await using var scope = connection.ScopeFactory.CreateAsyncScope();
var provider = scope.ServiceProvider; var renderer = scope.ServiceProvider.GetRequiredService<NoteRenderer>();
var renderer = provider.GetRequiredService<NoteRenderer>(); var intermediate = await renderer.RenderAsync(note, connection.Token.User);
var rendered = await renderer.RenderAsync(note, connection.Token.User); var rendered = EnforceRenoteReplyVisibility(intermediate, wrapped);
var message = new StreamingUpdateMessage var message = new StreamingUpdateMessage
{ {
Stream = [Name], Stream = [Name],
@ -90,7 +113,7 @@ public class PublicChannel(
} }
catch (Exception e) catch (Exception e)
{ {
Logger.LogError("Event handler OnNotePublished threw exception: {e}", e); _logger.LogError("Event handler OnNotePublished threw exception: {e}", e);
} }
} }
@ -98,13 +121,14 @@ public class PublicChannel(
{ {
try try
{ {
if (!IsApplicable(note)) return; var wrapped = IsApplicable(note);
if (wrapped == null) return;
if (IsFiltered(note)) return; if (IsFiltered(note)) return;
await using var scope = connection.ScopeFactory.CreateAsyncScope(); await using var scope = connection.ScopeFactory.CreateAsyncScope();
var provider = scope.ServiceProvider; var renderer = scope.ServiceProvider.GetRequiredService<NoteRenderer>();
var renderer = provider.GetRequiredService<NoteRenderer>(); var intermediate = await renderer.RenderAsync(note, connection.Token.User);
var rendered = await renderer.RenderAsync(note, connection.Token.User); var rendered = EnforceRenoteReplyVisibility(intermediate, wrapped);
var message = new StreamingUpdateMessage var message = new StreamingUpdateMessage
{ {
Stream = [Name], Stream = [Name],
@ -115,7 +139,7 @@ public class PublicChannel(
} }
catch (Exception e) catch (Exception e)
{ {
Logger.LogError("Event handler OnNoteUpdated threw exception: {e}", e); _logger.LogError("Event handler OnNoteUpdated threw exception: {e}", e);
} }
} }
@ -123,7 +147,7 @@ public class PublicChannel(
{ {
try try
{ {
if (!IsApplicable(note)) return; if (!IsApplicableBool(note)) return;
if (IsFiltered(note)) return; if (IsFiltered(note)) return;
var message = new StreamingUpdateMessage var message = new StreamingUpdateMessage
{ {
@ -135,7 +159,7 @@ public class PublicChannel(
} }
catch (Exception e) catch (Exception e)
{ {
Logger.LogError("Event handler OnNoteDeleted threw exception: {e}", e); _logger.LogError("Event handler OnNoteDeleted threw exception: {e}", e);
} }
} }
} }

View file

@ -3,14 +3,13 @@ using Iceshrimp.Backend.Controllers.Mastodon.Renderers;
using Iceshrimp.Backend.Controllers.Mastodon.Schemas.Entities; using Iceshrimp.Backend.Controllers.Mastodon.Schemas.Entities;
using Iceshrimp.Backend.Core.Database; using Iceshrimp.Backend.Core.Database;
using Iceshrimp.Backend.Core.Database.Tables; using Iceshrimp.Backend.Core.Database.Tables;
using Iceshrimp.Backend.Core.Events;
using Iceshrimp.Backend.Core.Middleware; using Iceshrimp.Backend.Core.Middleware;
namespace Iceshrimp.Backend.Controllers.Mastodon.Streaming.Channels; namespace Iceshrimp.Backend.Controllers.Mastodon.Streaming.Channels;
public class UserChannel(WebSocketConnection connection, bool notificationsOnly) : IChannel public class UserChannel(WebSocketConnection connection, bool notificationsOnly) : IChannel
{ {
public readonly ILogger<UserChannel> Logger = private readonly ILogger<UserChannel> _logger =
connection.Scope.ServiceProvider.GetRequiredService<ILogger<UserChannel>>(); connection.Scope.ServiceProvider.GetRequiredService<ILogger<UserChannel>>();
public string Name => notificationsOnly ? "user:notification" : "user"; public string Name => notificationsOnly ? "user:notification" : "user";
@ -27,9 +26,9 @@ public class UserChannel(WebSocketConnection connection, bool notificationsOnly)
if (!notificationsOnly) if (!notificationsOnly)
{ {
connection.EventService.NotePublished += OnNotePublished; connection.EventService.NotePublished += OnNotePublished;
connection.EventService.NoteUpdated += OnNoteUpdated; connection.EventService.NoteUpdated += OnNoteUpdated;
connection.EventService.NoteDeleted += OnNoteDeleted; connection.EventService.NoteDeleted += OnNoteDeleted;
} }
connection.EventService.Notification += OnNotification; connection.EventService.Notification += OnNotification;
@ -47,23 +46,26 @@ public class UserChannel(WebSocketConnection connection, bool notificationsOnly)
{ {
if (!notificationsOnly) if (!notificationsOnly)
{ {
connection.EventService.NotePublished -= OnNotePublished; connection.EventService.NotePublished -= OnNotePublished;
connection.EventService.NoteUpdated -= OnNoteUpdated; connection.EventService.NoteUpdated -= OnNoteUpdated;
connection.EventService.NoteDeleted -= OnNoteDeleted; connection.EventService.NoteDeleted -= OnNoteDeleted;
} }
connection.EventService.Notification -= OnNotification; connection.EventService.Notification -= OnNotification;
} }
private bool IsApplicable(Note note) => private NoteWithVisibilities? IsApplicable(Note note)
connection.Following.Prepend(connection.Token.User.Id).Contains(note.UserId) && {
EnforceRenoteReplyVisibility(note) is not { IsPureRenote: true, Renote: null }; if (IsApplicableBool(note)) return null;
var res = EnforceRenoteReplyVisibility(note);
return res is not { Note.IsPureRenote: true, Renote: null } ? null : res;
}
private bool IsApplicableBool(Note note) =>
connection.Following.Prepend(connection.Token.User.Id).Contains(note.UserId);
private bool IsApplicable(Notification notification) => notification.NotifieeId == connection.Token.User.Id; private bool IsApplicable(Notification notification) => notification.NotifieeId == connection.Token.User.Id;
private bool IsApplicable(UserInteraction interaction) => interaction.Actor.Id == connection.Token.User.Id ||
interaction.Object.Id == connection.Token.User.Id;
private bool IsFiltered(Note note) => connection.IsFiltered(note.User) || private bool IsFiltered(Note note) => connection.IsFiltered(note.User) ||
(note.Renote?.User != null && connection.IsFiltered(note.Renote.User)) || (note.Renote?.User != null && connection.IsFiltered(note.Renote.User)) ||
note.Renote?.Renote?.User != null && note.Renote?.Renote?.User != null &&
@ -73,26 +75,43 @@ public class UserChannel(WebSocketConnection connection, bool notificationsOnly)
(notification.Notifier != null && connection.IsFiltered(notification.Notifier)) || (notification.Notifier != null && connection.IsFiltered(notification.Notifier)) ||
(notification.Note != null && IsFiltered(notification.Note)); (notification.Note != null && IsFiltered(notification.Note));
private Note EnforceRenoteReplyVisibility(Note note) private NoteWithVisibilities EnforceRenoteReplyVisibility(Note note)
{ {
if (note.Renote?.IsVisibleFor(connection.Token.User, connection.Following) ?? false) var wrapped = new NoteWithVisibilities(note);
note.Renote = null; if (wrapped.Renote?.IsVisibleFor(connection.Token.User, connection.Following) ?? false)
if (note.Reply?.IsVisibleFor(connection.Token.User, connection.Following) ?? false) wrapped.Renote = null;
note.Reply = null;
return note; return wrapped;
}
private class NoteWithVisibilities(Note note)
{
public readonly Note Note = note;
public Note? Renote = note.Renote;
}
private static StatusEntity EnforceRenoteReplyVisibility(StatusEntity rendered, NoteWithVisibilities note)
{
var renote = note.Renote == null && rendered.Renote != null;
if (!renote) return rendered;
rendered = (StatusEntity)rendered.Clone();
if (renote) rendered.Renote = null;
return rendered;
} }
private async void OnNotePublished(object? _, Note note) private async void OnNotePublished(object? _, Note note)
{ {
try try
{ {
if (!IsApplicable(note)) return; var wrapped = IsApplicable(note);
if (wrapped == null) return;
if (IsFiltered(note)) return; if (IsFiltered(note)) return;
await using var scope = connection.ScopeFactory.CreateAsyncScope(); await using var scope = connection.ScopeFactory.CreateAsyncScope();
var renderer = scope.ServiceProvider.GetRequiredService<NoteRenderer>(); var renderer = scope.ServiceProvider.GetRequiredService<NoteRenderer>();
var rendered = await renderer.RenderAsync(note, connection.Token.User); var intermediate = await renderer.RenderAsync(note, connection.Token.User);
var rendered = EnforceRenoteReplyVisibility(intermediate, wrapped);
var message = new StreamingUpdateMessage var message = new StreamingUpdateMessage
{ {
Stream = [Name], Stream = [Name],
@ -103,7 +122,7 @@ public class UserChannel(WebSocketConnection connection, bool notificationsOnly)
} }
catch (Exception e) catch (Exception e)
{ {
Logger.LogError("Event handler OnNoteUpdated threw exception: {e}", e); _logger.LogError("Event handler OnNoteUpdated threw exception: {e}", e);
} }
} }
@ -111,12 +130,14 @@ public class UserChannel(WebSocketConnection connection, bool notificationsOnly)
{ {
try try
{ {
if (!IsApplicable(note)) return; var wrapped = IsApplicable(note);
if (wrapped == null) return;
if (IsFiltered(note)) return; if (IsFiltered(note)) return;
await using var scope = connection.ScopeFactory.CreateAsyncScope(); await using var scope = connection.ScopeFactory.CreateAsyncScope();
var renderer = scope.ServiceProvider.GetRequiredService<NoteRenderer>(); var renderer = scope.ServiceProvider.GetRequiredService<NoteRenderer>();
var rendered = await renderer.RenderAsync(note, connection.Token.User); var intermediate = await renderer.RenderAsync(note, connection.Token.User);
var rendered = EnforceRenoteReplyVisibility(intermediate, wrapped);
var message = new StreamingUpdateMessage var message = new StreamingUpdateMessage
{ {
Stream = [Name], Stream = [Name],
@ -127,7 +148,7 @@ public class UserChannel(WebSocketConnection connection, bool notificationsOnly)
} }
catch (Exception e) catch (Exception e)
{ {
Logger.LogError("Event handler OnNoteUpdated threw exception: {e}", e); _logger.LogError("Event handler OnNoteUpdated threw exception: {e}", e);
} }
} }
@ -135,7 +156,7 @@ public class UserChannel(WebSocketConnection connection, bool notificationsOnly)
{ {
try try
{ {
if (!IsApplicable(note)) return; if (!IsApplicableBool(note)) return;
if (IsFiltered(note)) return; if (IsFiltered(note)) return;
var message = new StreamingUpdateMessage var message = new StreamingUpdateMessage
{ {
@ -147,7 +168,7 @@ public class UserChannel(WebSocketConnection connection, bool notificationsOnly)
} }
catch (Exception e) catch (Exception e)
{ {
Logger.LogError("Event handler OnNoteDeleted threw exception: {e}", e); _logger.LogError("Event handler OnNoteDeleted threw exception: {e}", e);
} }
} }
@ -182,7 +203,7 @@ public class UserChannel(WebSocketConnection connection, bool notificationsOnly)
} }
catch (Exception e) catch (Exception e)
{ {
Logger.LogError("Event handler OnNotification threw exception: {e}", e); _logger.LogError("Event handler OnNotification threw exception: {e}", e);
} }
} }
} }

View file

@ -138,13 +138,13 @@ public sealed class StreamingConnectionAggregate : IDisposable
{ {
try try
{ {
if (!IsApplicable(data.note)) return; var wrapped = IsApplicable(data.note);
if (wrapped == null) return;
var recipients = FindRecipients(data.note); var recipients = FindRecipients(data.note);
if (recipients.connectionIds.Count == 0) return; if (recipients.connectionIds.Count == 0) return;
await _hub.Clients var rendered = EnforceRenoteReplyVisibility(await data.rendered(), wrapped);
.Clients(recipients.connectionIds) await _hub.Clients.Clients(recipients.connectionIds).NotePublished(recipients.timelines, rendered);
.NotePublished(recipients.timelines, await data.rendered());
} }
catch (Exception e) catch (Exception e)
{ {
@ -156,13 +156,13 @@ public sealed class StreamingConnectionAggregate : IDisposable
{ {
try try
{ {
if (!IsApplicable(data.note)) return; var wrapped = IsApplicable(data.note);
if (wrapped == null) return;
var recipients = FindRecipients(data.note); var recipients = FindRecipients(data.note);
if (recipients.connectionIds.Count == 0) return; if (recipients.connectionIds.Count == 0) return;
await _hub.Clients var rendered = EnforceRenoteReplyVisibility(await data.rendered(), wrapped);
.Clients(recipients.connectionIds) await _hub.Clients.Clients(recipients.connectionIds).NoteUpdated(recipients.timelines, rendered);
.NoteUpdated(recipients.timelines, await data.rendered());
} }
catch (Exception e) catch (Exception e)
{ {
@ -170,16 +170,29 @@ public sealed class StreamingConnectionAggregate : IDisposable
} }
} }
private bool IsApplicable(Note note) private static NoteResponse EnforceRenoteReplyVisibility(NoteResponse rendered, NoteWithVisibilities note)
{ {
if (_subscriptions.IsEmpty) return false; var renote = note.Renote == null && rendered.Renote != null;
if (!note.IsVisibleFor(_user, _following)) return false; var reply = note.Reply == null && rendered.Reply != null;
if (note.Visibility != Note.NoteVisibility.Public && !IsFollowingOrSelf(note.User)) return false; if (!renote && !reply) return rendered;
if (IsFiltered(note.User)) return false;
if (note.Reply != null && IsFiltered(note.Reply.User)) return false;
if (note.Renote != null && IsFiltered(note.Renote.User)) return false;
return EnforceRenoteReplyVisibility(note) is not { IsPureRenote: true, Renote: null }; rendered = (NoteResponse)rendered.Clone();
if (renote) rendered.Renote = null;
if (reply) rendered.Reply = null;
return rendered;
}
private NoteWithVisibilities? IsApplicable(Note note)
{
if (_subscriptions.IsEmpty) return null;
if (!note.IsVisibleFor(_user, _following)) return null;
if (note.Visibility != Note.NoteVisibility.Public && !IsFollowingOrSelf(note.User)) return null;
if (IsFiltered(note.User)) return null;
if (note.Reply != null && IsFiltered(note.Reply.User)) return null;
if (note.Renote != null && IsFiltered(note.Renote.User)) return null;
var res = EnforceRenoteReplyVisibility(note);
return res is not { Note.IsPureRenote: true, Renote: null } ? null : res;
} }
[SuppressMessage("ReSharper", "SuggestBaseTypeForParameter")] [SuppressMessage("ReSharper", "SuggestBaseTypeForParameter")]
@ -193,14 +206,22 @@ public sealed class StreamingConnectionAggregate : IDisposable
[SuppressMessage("ReSharper", "SuggestBaseTypeForParameter")] [SuppressMessage("ReSharper", "SuggestBaseTypeForParameter")]
private bool IsFollowingOrSelf(User user) => user.Id == _userId || _following.Contains(user.Id); private bool IsFollowingOrSelf(User user) => user.Id == _userId || _following.Contains(user.Id);
private Note EnforceRenoteReplyVisibility(Note note) private NoteWithVisibilities EnforceRenoteReplyVisibility(Note note)
{ {
if (note.Renote?.IsVisibleFor(_user, _following) ?? false) var wrapped = new NoteWithVisibilities(note);
note.Renote = null; if (wrapped.Renote?.IsVisibleFor(_user, _following) ?? false)
if (note.Reply?.IsVisibleFor(_user, _following) ?? false) wrapped.Renote = null;
note.Reply = null; if (wrapped.Reply?.IsVisibleFor(_user, _following) ?? false)
wrapped.Reply = null;
return note; return wrapped;
}
private class NoteWithVisibilities(Note note)
{
public readonly Note Note = note;
public Note? Reply = note.Reply;
public Note? Renote = note.Renote;
} }
private (List<string> connectionIds, List<StreamingTimeline> timelines) FindRecipients(Note note) private (List<string> connectionIds, List<StreamingTimeline> timelines) FindRecipients(Note note)

View file

@ -4,7 +4,7 @@ using JI = System.Text.Json.Serialization.JsonIgnoreAttribute;
namespace Iceshrimp.Shared.Schemas; namespace Iceshrimp.Shared.Schemas;
public class NoteResponse : NoteWithQuote public class NoteResponse : NoteWithQuote, ICloneable
{ {
[J("reply")] public NoteBase? Reply { get; set; } [J("reply")] public NoteBase? Reply { get; set; }
[J("replyId")] public string? ReplyId { get; set; } [J("replyId")] public string? ReplyId { get; set; }
@ -18,6 +18,8 @@ public class NoteResponse : NoteWithQuote
[JI(Condition = JsonIgnoreCondition.WhenWritingNull)] [JI(Condition = JsonIgnoreCondition.WhenWritingNull)]
[J("descendants")] [J("descendants")]
public List<NoteResponse>? Descendants { get; set; } public List<NoteResponse>? Descendants { get; set; }
public object Clone() => MemberwiseClone();
} }
public class NoteWithQuote : NoteBase public class NoteWithQuote : NoteBase