[backend/masto-client] Implement visibility/blocking/muting/list filters for timelines

This commit is contained in:
Laura Hausmann 2024-02-03 05:28:58 +01:00
parent 4aca474398
commit 4dd1997f45
No known key found for this signature in database
GPG key ID: D044E84C5BE01605
7 changed files with 148 additions and 54 deletions

View file

@ -28,6 +28,10 @@ public class MastodonTimelineController(DatabaseContext db, NoteRenderer noteRen
var res = await db.Notes
.IncludeCommonProperties()
.FilterByFollowingAndOwn(user)
.EnsureVisibleFor(user)
.FilterHiddenListMembers(user)
.FilterBlocked(user)
.FilterMuted(user)
.Paginate(query, 20, 40)
.RenderAllForMastodonAsync(noteRenderer);
@ -39,9 +43,13 @@ public class MastodonTimelineController(DatabaseContext db, NoteRenderer noteRen
[Produces("application/json")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(IEnumerable<Status>))]
public async Task<IActionResult> GetPublicTimeline(PaginationQuery query) {
var user = HttpContext.GetOauthUser() ?? throw new GracefulException("Failed to get user from HttpContext");
var res = await db.Notes
.IncludeCommonProperties()
.HasVisibility(Note.NoteVisibility.Public)
.FilterBlocked(user)
.FilterMuted(user)
.Paginate(query, 20, 40)
.RenderAllForMastodonAsync(noteRenderer);

View file

@ -107,9 +107,7 @@ public class DatabaseContext(DbContextOptions<DatabaseContext> options)
public static void Configure(DbContextOptionsBuilder optionsBuilder, NpgsqlDataSource dataSource) {
optionsBuilder.UseNpgsql(dataSource);
optionsBuilder.UseProjectables(options => {
options.CompatibilityMode(CompatibilityMode.Limited);
});
optionsBuilder.UseProjectables(options => { options.CompatibilityMode(CompatibilityMode.Full); });
}
protected override void OnModelCreating(ModelBuilder modelBuilder) {
@ -222,9 +220,9 @@ public class DatabaseContext(DbContextOptions<DatabaseContext> options)
entity.Property(e => e.BlockerId).HasComment("The blocker user ID.");
entity.Property(e => e.CreatedAt).HasComment("The created date of the Blocking.");
entity.HasOne(d => d.Blockee).WithMany(p => p.BlockingBlockees);
entity.HasOne(d => d.Blockee).WithMany(p => p.IncomingBlocks);
entity.HasOne(d => d.Blocker).WithMany(p => p.BlockingBlockers);
entity.HasOne(d => d.Blocker).WithMany(p => p.OutgoingBlocks);
});
modelBuilder.Entity<Channel>(entity => {
@ -513,9 +511,9 @@ public class DatabaseContext(DbContextOptions<DatabaseContext> options)
entity.Property(e => e.MuteeId).HasComment("The mutee user ID.");
entity.Property(e => e.MuterId).HasComment("The muter user ID.");
entity.HasOne(d => d.Mutee).WithMany(p => p.MutingMutees);
entity.HasOne(d => d.Mutee).WithMany(p => p.IncomingMutes);
entity.HasOne(d => d.Muter).WithMany(p => p.MutingMuters);
entity.HasOne(d => d.Muter).WithMany(p => p.OutgoingMutes);
});
modelBuilder.Entity<Note>(entity => {

View file

@ -36,10 +36,10 @@ public class Blocking {
public string BlockerId { get; set; } = null!;
[ForeignKey("BlockeeId")]
[InverseProperty(nameof(User.BlockingBlockees))]
[InverseProperty(nameof(User.IncomingBlocks))]
public virtual User Blockee { get; set; } = null!;
[ForeignKey("BlockerId")]
[InverseProperty(nameof(User.BlockingBlockers))]
[InverseProperty(nameof(User.OutgoingBlocks))]
public virtual User Blocker { get; set; } = null!;
}

View file

@ -39,10 +39,10 @@ public class Muting {
[Column("expiresAt")] public DateTime? ExpiresAt { get; set; }
[ForeignKey("MuteeId")]
[InverseProperty(nameof(User.MutingMutees))]
[InverseProperty(nameof(User.IncomingMutes))]
public virtual User Mutee { get; set; } = null!;
[ForeignKey("MuterId")]
[InverseProperty(nameof(User.MutingMuters))]
[InverseProperty(nameof(User.OutgoingMutes))]
public virtual User Muter { get; set; } = null!;
}

View file

@ -1,5 +1,7 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Diagnostics.CodeAnalysis;
using EntityFrameworkCore.Projectables;
using Microsoft.EntityFrameworkCore;
using NpgsqlTypes;
@ -73,6 +75,11 @@ public class Note {
[Column("visibility")] public NoteVisibility Visibility { get; set; }
[Projectable]
[SuppressMessage("ReSharper", "MergeIntoLogicalPattern",
Justification = "Projectable expression cannot contain patterns")]
public bool VisibilityIsPublicOrHome => Visibility == NoteVisibility.Public || Visibility == NoteVisibility.Home;
[Column("localOnly")] public bool LocalOnly { get; set; }
[Column("renoteCount")] public short RenoteCount { get; set; }
@ -179,15 +186,18 @@ public class Note {
[InverseProperty(nameof(ChannelNotePin.Note))]
public virtual ICollection<ChannelNotePin> ChannelNotePins { get; set; } = new List<ChannelNotePin>();
[InverseProperty(nameof(ClipNote.Note))] public virtual ICollection<ClipNote> ClipNotes { get; set; } = new List<ClipNote>();
[InverseProperty(nameof(ClipNote.Note))]
public virtual ICollection<ClipNote> ClipNotes { get; set; } = new List<ClipNote>();
[InverseProperty(nameof(Tables.HtmlNoteCacheEntry.Note))] public virtual HtmlNoteCacheEntry? HtmlNoteCacheEntry { get; set; }
[InverseProperty(nameof(Tables.HtmlNoteCacheEntry.Note))]
public virtual HtmlNoteCacheEntry? HtmlNoteCacheEntry { get; set; }
[InverseProperty(nameof(Renote))] public virtual ICollection<Note> InverseRenote { get; set; } = new List<Note>();
[InverseProperty(nameof(Reply))] public virtual ICollection<Note> InverseReply { get; set; } = new List<Note>();
[InverseProperty(nameof(NoteEdit.Note))] public virtual ICollection<NoteEdit> NoteEdits { get; set; } = new List<NoteEdit>();
[InverseProperty(nameof(NoteEdit.Note))]
public virtual ICollection<NoteEdit> NoteEdits { get; set; } = new List<NoteEdit>();
[InverseProperty(nameof(NoteFavorite.Note))]
public virtual ICollection<NoteFavorite> NoteFavorites { get; set; } = new List<NoteFavorite>();
@ -195,7 +205,8 @@ public class Note {
[InverseProperty(nameof(NoteReaction.Note))]
public virtual ICollection<NoteReaction> NoteReactions { get; set; } = new List<NoteReaction>();
[InverseProperty(nameof(NoteUnread.Note))] public virtual ICollection<NoteUnread> NoteUnreads { get; set; } = new List<NoteUnread>();
[InverseProperty(nameof(NoteUnread.Note))]
public virtual ICollection<NoteUnread> NoteUnreads { get; set; } = new List<NoteUnread>();
[InverseProperty(nameof(NoteWatching.Note))]
public virtual ICollection<NoteWatching> NoteWatchings { get; set; } = new List<NoteWatching>();
@ -203,13 +214,17 @@ public class Note {
[InverseProperty(nameof(Notification.Note))]
public virtual ICollection<Notification> Notifications { get; set; } = new List<Notification>();
[InverseProperty(nameof(Tables.Poll.Note))] public virtual Poll? Poll { get; set; }
[InverseProperty(nameof(Tables.Poll.Note))]
public virtual Poll? Poll { get; set; }
[InverseProperty(nameof(PollVote.Note))] public virtual ICollection<PollVote> PollVotes { get; set; } = new List<PollVote>();
[InverseProperty(nameof(PollVote.Note))]
public virtual ICollection<PollVote> PollVotes { get; set; } = new List<PollVote>();
[InverseProperty(nameof(Tables.PromoNote.Note))] public virtual PromoNote? PromoNote { get; set; }
[InverseProperty(nameof(Tables.PromoNote.Note))]
public virtual PromoNote? PromoNote { get; set; }
[InverseProperty(nameof(PromoRead.Note))] public virtual ICollection<PromoRead> PromoReads { get; set; } = new List<PromoRead>();
[InverseProperty(nameof(PromoRead.Note))]
public virtual ICollection<PromoRead> PromoReads { get; set; } = new List<PromoRead>();
[ForeignKey("RenoteId")]
[InverseProperty(nameof(InverseRenote))]
@ -225,4 +240,12 @@ public class Note {
[InverseProperty(nameof(UserNotePin.Note))]
public virtual ICollection<UserNotePin> UserNotePins { get; set; } = new List<UserNotePin>();
[Projectable]
public bool IsVisibleFor(User user) => VisibilityIsPublicOrHome
|| User == user
|| VisibleUserIds.Contains(user.Id)
|| Mentions.Any(p => p == user.Id)
|| (Visibility == NoteVisibility.Followers &&
(User.IsFollowedBy(user) || ReplyUserId == user.Id));
}

View file

@ -1,5 +1,4 @@
using System.Collections;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using EntityFrameworkCore.Projectables;
using Microsoft.EntityFrameworkCore;
@ -275,7 +274,8 @@ public class User {
[InverseProperty(nameof(AnnouncementRead.User))]
public virtual ICollection<AnnouncementRead> AnnouncementReads { get; set; } = new List<AnnouncementRead>();
[InverseProperty(nameof(Antenna.User))] public virtual ICollection<Antenna> Antennas { get; set; } = new List<Antenna>();
[InverseProperty(nameof(Antenna.User))]
public virtual ICollection<Antenna> Antennas { get; set; } = new List<Antenna>();
[InverseProperty(nameof(App.User))] public virtual ICollection<App> Apps { get; set; } = new List<App>();
@ -294,20 +294,26 @@ public class User {
[InverseProperty(nameof(DriveFile.UserBanner))]
public virtual DriveFile? Banner { get; set; }
[InverseProperty(nameof(Blocking.Blockee))]
public virtual ICollection<Blocking> BlockingBlockees { get; set; } = new List<Blocking>();
[InverseProperty(nameof(Tables.Blocking.Blockee))]
public virtual ICollection<Blocking> IncomingBlocks { get; set; } = new List<Blocking>();
[InverseProperty(nameof(Blocking.Blocker))]
public virtual ICollection<Blocking> BlockingBlockers { get; set; } = new List<Blocking>();
[InverseProperty(nameof(Tables.Blocking.Blocker))]
public virtual ICollection<Blocking> OutgoingBlocks { get; set; } = new List<Blocking>();
[Projectable] public virtual IEnumerable<User> BlockedBy => IncomingBlocks.Select(p => p.Blocker);
[Projectable] public virtual IEnumerable<User> Blocking => OutgoingBlocks.Select(p => p.Blockee);
[InverseProperty(nameof(ChannelFollowing.Follower))]
public virtual ICollection<ChannelFollowing> ChannelFollowings { get; set; } = new List<ChannelFollowing>();
[InverseProperty(nameof(Channel.User))] public virtual ICollection<Channel> Channels { get; set; } = new List<Channel>();
[InverseProperty(nameof(Channel.User))]
public virtual ICollection<Channel> Channels { get; set; } = new List<Channel>();
[InverseProperty(nameof(Clip.User))] public virtual ICollection<Clip> Clips { get; set; } = new List<Clip>();
[InverseProperty(nameof(DriveFile.User))] public virtual ICollection<DriveFile> DriveFiles { get; set; } = new List<DriveFile>();
[InverseProperty(nameof(DriveFile.User))]
public virtual ICollection<DriveFile> DriveFiles { get; set; } = new List<DriveFile>();
[InverseProperty(nameof(DriveFolder.User))]
public virtual ICollection<DriveFolder> DriveFolders { get; set; } = new List<DriveFolder>();
@ -324,17 +330,9 @@ public class User {
[InverseProperty(nameof(Tables.Following.Follower))]
public virtual ICollection<Following> OutgoingFollowRelationships { get; set; } = new List<Following>();
[Projectable]
public virtual IEnumerable<User> Followers => IncomingFollowRelationships.Select(p => p.Follower);
[Projectable] public virtual IEnumerable<User> Followers => IncomingFollowRelationships.Select(p => p.Follower);
[Projectable]
public virtual IEnumerable<User> Following => OutgoingFollowRelationships.Select(p => p.Followee);
[Projectable]
public bool IsFollowedBy(User user) => Followers.Contains(user);
[Projectable]
public bool IsFollowing(User user) => Following.Contains(user);
[Projectable] public virtual IEnumerable<User> Following => OutgoingFollowRelationships.Select(p => p.Followee);
[InverseProperty(nameof(GalleryLike.User))]
public virtual ICollection<GalleryLike> GalleryLikes { get; set; } = new List<GalleryLike>();
@ -342,7 +340,8 @@ public class User {
[InverseProperty(nameof(GalleryPost.User))]
public virtual ICollection<GalleryPost> GalleryPosts { get; set; } = new List<GalleryPost>();
[InverseProperty(nameof(Tables.HtmlUserCacheEntry.User))] public virtual HtmlUserCacheEntry? HtmlUserCacheEntry { get; set; }
[InverseProperty(nameof(Tables.HtmlUserCacheEntry.User))]
public virtual HtmlUserCacheEntry? HtmlUserCacheEntry { get; set; }
[InverseProperty(nameof(MessagingMessage.Recipient))]
public virtual ICollection<MessagingMessage> MessagingMessageRecipients { get; set; } =
@ -354,9 +353,15 @@ public class User {
[InverseProperty(nameof(ModerationLog.User))]
public virtual ICollection<ModerationLog> ModerationLogs { get; set; } = new List<ModerationLog>();
[InverseProperty(nameof(Muting.Mutee))] public virtual ICollection<Muting> MutingMutees { get; set; } = new List<Muting>();
[InverseProperty(nameof(Tables.Muting.Mutee))]
public virtual ICollection<Muting> IncomingMutes { get; set; } = new List<Muting>();
[InverseProperty(nameof(Muting.Muter))] public virtual ICollection<Muting> MutingMuters { get; set; } = new List<Muting>();
[InverseProperty(nameof(Tables.Muting.Muter))]
public virtual ICollection<Muting> OutgoingMutes { get; set; } = new List<Muting>();
[Projectable] public virtual IEnumerable<User> MutedBy => IncomingMutes.Select(p => p.Muter);
[Projectable] public virtual IEnumerable<User> Muting => OutgoingMutes.Select(p => p.Mutee);
[InverseProperty(nameof(NoteFavorite.User))]
public virtual ICollection<NoteFavorite> NoteFavorites { get; set; } = new List<NoteFavorite>();
@ -367,7 +372,8 @@ public class User {
[InverseProperty(nameof(NoteThreadMuting.User))]
public virtual ICollection<NoteThreadMuting> NoteThreadMutings { get; set; } = new List<NoteThreadMuting>();
[InverseProperty(nameof(NoteUnread.User))] public virtual ICollection<NoteUnread> NoteUnreads { get; set; } = new List<NoteUnread>();
[InverseProperty(nameof(NoteUnread.User))]
public virtual ICollection<NoteUnread> NoteUnreads { get; set; } = new List<NoteUnread>();
[InverseProperty(nameof(NoteWatching.User))]
public virtual ICollection<NoteWatching> NoteWatchings { get; set; } = new List<NoteWatching>();
@ -380,9 +386,11 @@ public class User {
[InverseProperty(nameof(Notification.Notifier))]
public virtual ICollection<Notification> NotificationNotifiers { get; set; } = new List<Notification>();
[InverseProperty(nameof(OauthToken.User))] public virtual ICollection<OauthToken> OauthTokens { get; set; } = new List<OauthToken>();
[InverseProperty(nameof(OauthToken.User))]
public virtual ICollection<OauthToken> OauthTokens { get; set; } = new List<OauthToken>();
[InverseProperty(nameof(PageLike.User))] public virtual ICollection<PageLike> PageLikes { get; set; } = new List<PageLike>();
[InverseProperty(nameof(PageLike.User))]
public virtual ICollection<PageLike> PageLikes { get; set; } = new List<PageLike>();
[InverseProperty(nameof(Page.User))] public virtual ICollection<Page> Pages { get; set; } = new List<Page>();
@ -390,9 +398,11 @@ public class User {
public virtual ICollection<PasswordResetRequest> PasswordResetRequests { get; set; } =
new List<PasswordResetRequest>();
[InverseProperty(nameof(PollVote.User))] public virtual ICollection<PollVote> PollVotes { get; set; } = new List<PollVote>();
[InverseProperty(nameof(PollVote.User))]
public virtual ICollection<PollVote> PollVotes { get; set; } = new List<PollVote>();
[InverseProperty(nameof(PromoRead.User))] public virtual ICollection<PromoRead> PromoReads { get; set; } = new List<PromoRead>();
[InverseProperty(nameof(PromoRead.User))]
public virtual ICollection<PromoRead> PromoReads { get; set; } = new List<PromoRead>();
[InverseProperty(nameof(RegistryItem.User))]
public virtual ICollection<RegistryItem> RegistryItems { get; set; } = new List<RegistryItem>();
@ -403,9 +413,11 @@ public class User {
[InverseProperty(nameof(RenoteMuting.Muter))]
public virtual ICollection<RenoteMuting> RenoteMutingMuters { get; set; } = new List<RenoteMuting>();
[InverseProperty(nameof(Session.User))] public virtual ICollection<Session> Sessions { get; set; } = new List<Session>();
[InverseProperty(nameof(Session.User))]
public virtual ICollection<Session> Sessions { get; set; } = new List<Session>();
[InverseProperty(nameof(Signin.User))] public virtual ICollection<Signin> Signins { get; set; } = new List<Signin>();
[InverseProperty(nameof(Signin.User))]
public virtual ICollection<Signin> Signins { get; set; } = new List<Signin>();
[InverseProperty(nameof(SwSubscription.User))]
public virtual ICollection<SwSubscription> SwSubscriptions { get; set; } = new List<SwSubscription>();
@ -417,24 +429,48 @@ public class User {
[InverseProperty(nameof(UserGroupMember.User))]
public virtual ICollection<UserGroupMember> UserGroupMemberships { get; set; } = new List<UserGroupMember>();
[InverseProperty(nameof(UserGroup.User))] public virtual ICollection<UserGroup> UserGroups { get; set; } = new List<UserGroup>();
[InverseProperty(nameof(UserGroup.User))]
public virtual ICollection<UserGroup> UserGroups { get; set; } = new List<UserGroup>();
[InverseProperty(nameof(Tables.UserKeypair.User))] public virtual UserKeypair? UserKeypair { get; set; }
[InverseProperty(nameof(Tables.UserKeypair.User))]
public virtual UserKeypair? UserKeypair { get; set; }
[InverseProperty(nameof(UserListMember.User))]
public virtual ICollection<UserListMember> UserListMembers { get; set; } = new List<UserListMember>();
[InverseProperty(nameof(UserList.User))] public virtual ICollection<UserList> UserLists { get; set; } = new List<UserList>();
[InverseProperty(nameof(UserList.User))]
public virtual ICollection<UserList> UserLists { get; set; } = new List<UserList>();
[InverseProperty(nameof(UserNotePin.User))]
public virtual ICollection<UserNotePin> UserNotePins { get; set; } = new List<UserNotePin>();
[InverseProperty(nameof(Tables.UserProfile.User))] public virtual UserProfile? UserProfile { get; set; }
[InverseProperty(nameof(Tables.UserProfile.User))]
public virtual UserProfile? UserProfile { get; set; }
[InverseProperty(nameof(Tables.UserPublickey.User))] public virtual UserPublickey? UserPublickey { get; set; }
[InverseProperty(nameof(Tables.UserPublickey.User))]
public virtual UserPublickey? UserPublickey { get; set; }
[InverseProperty(nameof(UserSecurityKey.User))]
public virtual ICollection<UserSecurityKey> UserSecurityKeys { get; set; } = new List<UserSecurityKey>();
[InverseProperty(nameof(Webhook.User))] public virtual ICollection<Webhook> Webhooks { get; set; } = new List<Webhook>();
[InverseProperty(nameof(Webhook.User))]
public virtual ICollection<Webhook> Webhooks { get; set; } = new List<Webhook>();
[Projectable]
public bool IsBlockedBy(User user) => BlockedBy.Contains(user);
[Projectable]
public bool IsBlocking(User user) => Blocking.Contains(user);
[Projectable]
public bool IsFollowedBy(User user) => Followers.Contains(user);
[Projectable]
public bool IsFollowing(User user) => Following.Contains(user);
[Projectable]
public bool IsMutedBy(User user) => MutedBy.Contains(user);
[Projectable]
public bool IsMuting(User user) => Muting.Contains(user);
}

View file

@ -53,6 +53,35 @@ public static class NoteQueryableExtensions {
return query.Where(note => note.User == user || note.User.IsFollowedBy(user));
}
public static IQueryable<Note> EnsureVisibleFor(this IQueryable<Note> query, User? user) {
if (user == null)
return query.Where(note => note.VisibilityIsPublicOrHome)
.Where(note => !note.LocalOnly);
return query.Where(note => note.IsVisibleFor(user));
}
public static IQueryable<Note> FilterBlocked(this IQueryable<Note> query, User user) {
return query.Where(note => !note.User.IsBlocking(user) && !note.User.IsBlockedBy(user))
.Where(note => note.Renote == null ||
(!note.Renote.User.IsBlockedBy(user) && !note.Renote.User.IsBlocking(user)))
.Where(note => note.Reply == null ||
(!note.Reply.User.IsBlockedBy(user) && !note.Reply.User.IsBlocking(user)));
}
public static IQueryable<Note> FilterMuted(this IQueryable<Note> query, User user) {
//TODO: handle muted instances
return query.Where(note => !note.User.IsMuting(user))
.Where(note => note.Renote == null || !note.Renote.User.IsMuting(user))
.Where(note => note.Reply == null || !note.Reply.User.IsMuting(user));
}
public static IQueryable<Note> FilterHiddenListMembers(this IQueryable<Note> query, User user) {
return query.Where(note => note.User.UserListMembers.Any(p => p.UserList.User == user &&
p.UserList.HideFromHomeTl));
}
public static async Task<IEnumerable<Status>> RenderAllForMastodonAsync(
this IQueryable<Note> notes, NoteRenderer renderer) {
var list = await notes.ToListAsync();