[backend/core] Support note filters (ISH-97)

This commit is contained in:
Laura Hausmann 2024-03-31 19:45:09 +02:00
parent bde33e09bf
commit 62dde50af2
No known key found for this signature in database
GPG key ID: D044E84C5BE01605
22 changed files with 6971 additions and 111 deletions

View file

@ -365,7 +365,7 @@ public class AccountController(
.FilterIncomingBlocks(user)
.Paginate(query, ControllerContext)
.PrecomputeVisibilities(user)
.RenderAllForMastodonAsync(noteRenderer, user);
.RenderAllForMastodonAsync(noteRenderer, user, Filter.FilterContext.Accounts);
return Ok(res);
}

View file

@ -64,7 +64,7 @@ public class ConversationsController(
.DistinctBy(p => p.Id)
.ToList();
var notes = await noteRenderer.RenderManyAsync(conversations.Select(p => p.LastNote), user, accounts);
var notes = await noteRenderer.RenderManyAsync(conversations.Select(p => p.LastNote), user, accounts: accounts);
var res = conversations.Select(p => new ConversationEntity
{
@ -136,7 +136,7 @@ public class ConversationsController(
{
Id = conversation.Id,
Unread = conversation.Unread,
LastStatus = await noteRenderer.RenderAsync(conversation.LastNote, user, noteRendererDto),
LastStatus = await noteRenderer.RenderAsync(conversation.LastNote, user, data: noteRendererDto),
Accounts = accounts
};

View file

@ -0,0 +1,279 @@
using System.Net.Mime;
using Iceshrimp.Backend.Controllers.Mastodon.Attributes;
using Iceshrimp.Backend.Controllers.Mastodon.Renderers;
using Iceshrimp.Backend.Controllers.Mastodon.Schemas;
using Iceshrimp.Backend.Controllers.Mastodon.Schemas.Entities;
using Iceshrimp.Backend.Core.Database;
using Iceshrimp.Backend.Core.Database.Tables;
using Iceshrimp.Backend.Core.Extensions;
using Iceshrimp.Backend.Core.Middleware;
using Iceshrimp.Backend.Core.Queues;
using Iceshrimp.Backend.Core.Services;
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore;
namespace Iceshrimp.Backend.Controllers.Mastodon;
[MastodonApiController]
[Route("/api/v2/filters")]
[Authenticate]
[EnableRateLimiting("sliding")]
[EnableCors("mastodon")]
[Produces(MediaTypeNames.Application.Json)]
public class FilterController(DatabaseContext db, QueueService queueSvc) : ControllerBase
{
[HttpGet]
[Authorize("read:filters")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(IEnumerable<FilterEntity>))]
public async Task<IActionResult> GetFilters()
{
var user = HttpContext.GetUserOrFail();
var filters = await db.Filters.Where(p => p.User == user).ToListAsync();
var res = filters.Select(FilterRenderer.RenderOne);
return Ok(res);
}
[HttpGet("{id:long}")]
[Authorize("read:filters")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(IEnumerable<FilterEntity>))]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))]
public async Task<IActionResult> GetFilter(long id)
{
var user = HttpContext.GetUserOrFail();
var filter = await db.Filters.Where(p => p.User == user && p.Id == id).FirstOrDefaultAsync() ??
throw GracefulException.RecordNotFound();
return Ok(FilterRenderer.RenderOne(filter));
}
[HttpPost]
[Authorize("write:filters")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(IEnumerable<FilterEntity>))]
public async Task<IActionResult> CreateFilter([FromHybrid] FilterSchemas.CreateFilterRequest request)
{
var user = HttpContext.GetUserOrFail();
var action = request.Action switch
{
"warn" => Filter.FilterAction.Warn,
"hide" => Filter.FilterAction.Hide,
_ => throw GracefulException.BadRequest($"Unknown action: {request.Action}")
};
var context = request.Context.Select(p => p switch
{
"home" => Filter.FilterContext.Home,
"notifications" => Filter.FilterContext.Notifications,
"public" => Filter.FilterContext.Public,
"thread" => Filter.FilterContext.Threads,
"account" => Filter.FilterContext.Accounts,
_ => throw GracefulException.BadRequest($"Unknown filter context: {p}")
});
var contextList = context.ToList();
if (contextList.Contains(Filter.FilterContext.Home))
contextList.Add(Filter.FilterContext.Lists);
DateTime? expiry = request.ExpiresIn.HasValue
? DateTime.UtcNow + TimeSpan.FromSeconds(request.ExpiresIn.Value)
: null;
var keywords = request.Keywords.Select(p => p.WholeWord ? $"\"{p.Keyword}\"" : p.Keyword).ToList();
var filter = new Filter
{
Name = request.Title,
User = user,
Contexts = contextList,
Action = action,
Expiry = expiry,
Keywords = keywords
};
db.Add(filter);
await db.SaveChangesAsync();
if (expiry.HasValue)
{
var data = new FilterExpiryJobData { FilterId = filter.Id };
await queueSvc.BackgroundTaskQueue.ScheduleAsync(data, expiry.Value);
}
return Ok(FilterRenderer.RenderOne(filter));
}
[HttpPut("{id:long}")]
[Authorize("write:filters")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(IEnumerable<FilterEntity>))]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))]
public async Task<IActionResult> UpdateFilter(long id, [FromHybrid] FilterSchemas.UpdateFilterRequest request)
{
var user = HttpContext.GetUserOrFail();
var filter = await db.Filters.FirstOrDefaultAsync(p => p.User == user && p.Id == id) ??
throw GracefulException.RecordNotFound();
var action = request.Action switch
{
"warn" => Filter.FilterAction.Warn,
"hide" => Filter.FilterAction.Hide,
_ => throw GracefulException.BadRequest($"Unknown action: {request.Action}")
};
var context = request.Context.Select(p => p switch
{
"home" => Filter.FilterContext.Home,
"notifications" => Filter.FilterContext.Notifications,
"public" => Filter.FilterContext.Public,
"thread" => Filter.FilterContext.Threads,
"account" => Filter.FilterContext.Accounts,
_ => throw GracefulException.BadRequest($"Unknown filter context: {p}")
});
var contextList = context.ToList();
if (contextList.Contains(Filter.FilterContext.Home))
contextList.Add(Filter.FilterContext.Lists);
DateTime? expiry = request.ExpiresIn.HasValue
? DateTime.UtcNow + TimeSpan.FromSeconds(request.ExpiresIn.Value)
: null;
foreach (var kw in request.Keywords.Where(p => p is { Id: not null, Destroy: false }))
filter.Keywords[int.Parse(kw.Id!.Split('-')[1])] = kw.WholeWord ? $"\"{kw.Keyword}\"" : kw.Keyword;
var destroy = request.Keywords.Where(p => p is { Id: not null, Destroy: true }).Select(p => p.Id);
var @new = request.Keywords.Where(p => p.Id == null)
.Select(p => p.WholeWord ? $"\"{p.Keyword}\"" : p.Keyword)
.ToList();
var keywords = filter.Keywords.Where((_, i) => !destroy.Contains(i.ToString())).Concat(@new).ToList();
filter.Name = request.Title;
filter.Contexts = contextList;
filter.Action = action;
filter.Expiry = expiry;
filter.Keywords = keywords;
db.Update(filter);
await db.SaveChangesAsync();
if (expiry.HasValue)
{
var data = new FilterExpiryJobData { FilterId = filter.Id };
await queueSvc.BackgroundTaskQueue.ScheduleAsync(data, expiry.Value);
}
return Ok(FilterRenderer.RenderOne(filter));
}
[HttpDelete("{id:long}")]
[Authorize("write:filters")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(object))]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))]
public async Task<IActionResult> DeleteFilter(long id)
{
var user = HttpContext.GetUserOrFail();
var filter = await db.Filters.Where(p => p.User == user && p.Id == id).FirstOrDefaultAsync() ??
throw GracefulException.RecordNotFound();
db.Remove(filter);
await db.SaveChangesAsync();
return Ok(new object());
}
[HttpGet("{id:long}/keywords")]
[Authorize("read:filters")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(IEnumerable<FilterKeyword>))]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))]
public async Task<IActionResult> GetFilterKeywords(long id)
{
var user = HttpContext.GetUserOrFail();
var filter = await db.Filters.Where(p => p.User == user && p.Id == id).FirstOrDefaultAsync() ??
throw GracefulException.RecordNotFound();
return Ok(filter.Keywords.Select((p, i) => new FilterKeyword(p, filter.Id, i)));
}
[HttpPost("{id:long}/keywords")]
[Authorize("write:filters")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(FilterKeyword))]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))]
public async Task<IActionResult> AddFilterKeyword(
long id, [FromHybrid] FilterSchemas.FilterKeywordsAttributes request
)
{
var user = HttpContext.GetUserOrFail();
var filter = await db.Filters.Where(p => p.User == user && p.Id == id).FirstOrDefaultAsync() ??
throw GracefulException.RecordNotFound();
var keyword = request.WholeWord ? $"\"{request.Keyword}\"" : request.Keyword;
filter.Keywords.Add(keyword);
db.Update(keyword);
await db.SaveChangesAsync();
return Ok(new FilterKeyword(keyword, filter.Id, filter.Keywords.Count - 1));
}
[HttpGet("keywords/{filterId:long}-{keywordId:int}")]
[Authorize("read:filters")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(FilterKeyword))]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))]
public async Task<IActionResult> GetFilterKeyword(long filterId, int keywordId)
{
var user = HttpContext.GetUserOrFail();
var filter = await db.Filters.Where(p => p.User == user && p.Id == filterId).FirstOrDefaultAsync() ??
throw GracefulException.RecordNotFound();
if (filter.Keywords.Count < keywordId)
throw GracefulException.RecordNotFound();
return Ok(new FilterKeyword(filter.Keywords[keywordId], filter.Id, keywordId));
}
[HttpPut("keywords/{filterId:long}-{keywordId:int}")]
[Authorize("write:filters")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(FilterKeyword))]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))]
public async Task<IActionResult> UpdateFilterKeyword(
long filterId, int keywordId, [FromHybrid] FilterSchemas.FilterKeywordsAttributes request
)
{
var user = HttpContext.GetUserOrFail();
var filter = await db.Filters.Where(p => p.User == user && p.Id == filterId).FirstOrDefaultAsync() ??
throw GracefulException.RecordNotFound();
if (filter.Keywords.Count < keywordId)
throw GracefulException.RecordNotFound();
filter.Keywords[keywordId] = request.WholeWord ? $"\"{request.Keyword}\"" : request.Keyword;
db.Update(filter);
await db.SaveChangesAsync();
return Ok(new FilterKeyword(filter.Keywords[keywordId], filter.Id, keywordId));
}
[HttpDelete("keywords/{filterId:long}-{keywordId:int}")]
[Authorize("write:filters")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(object))]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))]
public async Task<IActionResult> DeleteFilterKeyword(long filterId, int keywordId)
{
var user = HttpContext.GetUserOrFail();
var filter = await db.Filters.Where(p => p.User == user && p.Id == filterId).FirstOrDefaultAsync() ??
throw GracefulException.RecordNotFound();
if (filter.Keywords.Count < keywordId)
throw GracefulException.RecordNotFound();
filter.Keywords.RemoveAt(keywordId);
db.Update(filter);
await db.SaveChangesAsync();
return Ok(new object());
}
//TODO: status filters (first: what are they even for?)
}

View file

@ -0,0 +1,32 @@
using Iceshrimp.Backend.Controllers.Mastodon.Schemas.Entities;
using Iceshrimp.Backend.Core.Database.Tables;
using Iceshrimp.Backend.Core.Extensions;
namespace Iceshrimp.Backend.Controllers.Mastodon.Renderers;
public static class FilterRenderer
{
public static FilterEntity RenderOne(Filter filter)
{
var context = filter.Contexts.Select(c => c switch
{
Filter.FilterContext.Home => "home",
Filter.FilterContext.Lists => "home",
Filter.FilterContext.Threads => "thread",
Filter.FilterContext.Notifications => "notifications",
Filter.FilterContext.Accounts => "account",
Filter.FilterContext.Public => "public",
_ => throw new ArgumentOutOfRangeException(nameof(c))
});
return new FilterEntity
{
Id = filter.Id.ToString(),
Keywords = filter.Keywords.Select((p, i) => new FilterKeyword(p, filter.Id, i)).ToList(),
Context = context.Distinct().ToList(),
Title = filter.Name,
ExpiresAt = filter.Expiry?.ToStringIso8601Like(),
FilterAction = filter.Action == Filter.FilterAction.Hide ? "hide" : "warn"
};
}
}

View file

@ -3,6 +3,7 @@ using Iceshrimp.Backend.Core.Configuration;
using Iceshrimp.Backend.Core.Database;
using Iceshrimp.Backend.Core.Database.Tables;
using Iceshrimp.Backend.Core.Extensions;
using Iceshrimp.Backend.Core.Helpers;
using Iceshrimp.Backend.Core.Helpers.LibMfm.Conversion;
using Iceshrimp.Backend.Core.Services;
using Microsoft.EntityFrameworkCore;
@ -19,14 +20,16 @@ public class NoteRenderer(
EmojiService emojiSvc
)
{
public async Task<StatusEntity> RenderAsync(Note note, User? user, NoteRendererDto? data = null, int recurse = 2)
public async Task<StatusEntity> RenderAsync(
Note note, User? user, Filter.FilterContext? filterContext = null, NoteRendererDto? data = null, int recurse = 2
)
{
var uri = note.Uri ?? note.GetPublicUri(config.Value);
var renote = note is { Renote: not null, IsQuote: false } && recurse > 0
? await RenderAsync(note.Renote, user, data, 0)
? await RenderAsync(note.Renote, user, filterContext, data, 0)
: null;
var quote = note is { Renote: not null, IsQuote: true } && recurse > 0
? await RenderAsync(note.Renote, user, data, --recurse)
? await RenderAsync(note.Renote, user, filterContext, data, --recurse)
: null;
var text = note.Text;
string? quoteUri = null;
@ -83,6 +86,10 @@ public class NoteRenderer(
? (data?.Polls ?? await GetPolls([note], user)).FirstOrDefault(p => p.Id == note.Id)
: null;
var filters = data?.Filters ?? await GetFilters(user, filterContext);
var filtered = FilterHelper.IsFiltered([note, note.Reply, note.Renote, note.Renote?.Renote], filters);
var filterResult = GetFilterResult(filtered);
var res = new StatusEntity
{
Id = note.Id,
@ -113,12 +120,21 @@ public class NoteRenderer(
Attachments = attachments,
Emojis = noteEmoji,
Poll = poll,
Reactions = reactions
Reactions = reactions,
Filtered = filterResult
};
return res;
}
private static List<FilterResultEntity> GetFilterResult((Filter filter, string keyword)? filtered)
{
if (filtered == null) return [];
var (filter, keyword) = filtered.Value;
return [new FilterResultEntity { Filter = FilterRenderer.RenderOne(filter), KeywordMatches = [keyword] }];
}
private async Task<List<MentionEntity>> GetMentions(List<Note> notes)
{
if (notes.Count == 0) return [];
@ -252,8 +268,14 @@ public class NoteRenderer(
.ToListAsync();
}
private async Task<List<Filter>> GetFilters(User? user, Filter.FilterContext? filterContext)
{
if (filterContext == null) return [];
return await db.Filters.Where(p => p.User == user && p.Contexts.Contains(filterContext.Value)).ToListAsync();
}
public async Task<IEnumerable<StatusEntity>> RenderManyAsync(
IEnumerable<Note> notes, User? user, List<AccountEntity>? accounts = null
IEnumerable<Note> notes, User? user, Filter.FilterContext? filterContext = null, List<AccountEntity>? accounts = null
)
{
var noteList = notes.SelectMany<Note, Note?>(p => [p, p.Renote])
@ -275,10 +297,11 @@ public class NoteRenderer(
PinnedNotes = await GetPinnedNotes(noteList, user),
Renotes = await GetRenotes(noteList, user),
Emoji = await GetEmoji(noteList),
Reactions = await GetReactions(noteList, user)
Reactions = await GetReactions(noteList, user),
Filters = await GetFilters(user, filterContext)
};
return await noteList.Select(p => RenderAsync(p, user, data)).AwaitAllAsync();
return await noteList.Select(p => RenderAsync(p, user, filterContext, data)).AwaitAllAsync();
}
public class NoteRendererDto
@ -293,6 +316,7 @@ public class NoteRenderer(
public List<PollEntity>? Polls;
public List<ReactionEntity>? Reactions;
public List<string>? Renotes;
public List<Filter>? Filters;
public bool Source;
}

View file

@ -18,7 +18,8 @@ public class NotificationRenderer(NoteRenderer noteRenderer, UserRenderer userRe
var note = targetNote != null
? statuses?.FirstOrDefault(p => p.Id == targetNote.Id) ??
await noteRenderer.RenderAsync(targetNote, user, new NoteRenderer.NoteRendererDto { Accounts = accounts })
await noteRenderer.RenderAsync(targetNote, user, Filter.FilterContext.Notifications,
new NoteRenderer.NoteRendererDto { Accounts = accounts })
: null;
var notifier = accounts?.FirstOrDefault(p => p.Id == dbNotifier.Id) ??
@ -59,7 +60,8 @@ public class NotificationRenderer(NoteRenderer noteRenderer, UserRenderer userRe
.Select(p => p.Note?.Renote)
.Where(p => p != null))
.Cast<Note>()
.DistinctBy(p => p.Id), user, accounts);
.DistinctBy(p => p.Id),
user, Filter.FilterContext.Notifications, accounts);
return await notificationList
.Select(p => RenderAsync(p, user, accounts, notes))

View file

@ -0,0 +1,29 @@
using J = System.Text.Json.Serialization.JsonPropertyNameAttribute;
namespace Iceshrimp.Backend.Controllers.Mastodon.Schemas.Entities;
public class FilterEntity
{
[J("id")] public required string Id { get; set; }
[J("title")] public required string Title { get; set; }
[J("context")] public required List<string> Context { get; set; }
[J("expires_at")] public required string? ExpiresAt { get; set; }
[J("filter_action")] public required string FilterAction { get; set; }
[J("keywords")] public required List<FilterKeyword> Keywords { get; set; }
[J("statuses")] public object[] Statuses => []; //TODO
}
public class FilterKeyword
{
public FilterKeyword(string keyword, long filterId, int keywordId)
{
Id = $"{filterId}-{keywordId}";
WholeWord = keyword.StartsWith('"') && keyword.EndsWith('"') && keyword.Length > 2;
Keyword = WholeWord ? keyword[1..^1] : keyword;
}
[J("id")] public string Id { get; }
[J("keyword")] public string Keyword { get; }
[J("whole_word")] public bool WholeWord { get; }
}

View file

@ -0,0 +1,11 @@
using J = System.Text.Json.Serialization.JsonPropertyNameAttribute;
namespace Iceshrimp.Backend.Controllers.Mastodon.Schemas.Entities;
public class FilterResultEntity
{
[J("filter")] public required FilterEntity Filter { get; set; }
[J("keyword_matches")] public required List<string> KeywordMatches { get; set; }
[J("status_matches")] public List<string> StatusMatches => []; //TODO
}

View file

@ -41,13 +41,13 @@ public class StatusEntity : IEntity
[J("poll")] public required PollEntity? Poll { get; set; }
[J("filtered")] public required List<FilterResultEntity> Filtered { get; set; }
[J("mentions")] public required List<MentionEntity> Mentions { get; set; }
[J("media_attachments")] public required List<AttachmentEntity> Attachments { get; set; }
[J("emojis")] public required List<EmojiEntity> Emojis { get; set; }
[J("reactions")] public required List<ReactionEntity> Reactions { get; set; }
[J("tags")] public object[] Tags => []; //FIXME
[J("filtered")] public object[] Filtered => []; //FIXME
[J("card")] public object? Card => null; //FIXME
[J("application")] public object? Application => null; //FIXME

View file

@ -0,0 +1,56 @@
using J = System.Text.Json.Serialization.JsonPropertyNameAttribute;
using JR = System.Text.Json.Serialization.JsonRequiredAttribute;
using B = Microsoft.AspNetCore.Mvc.BindPropertyAttribute;
namespace Iceshrimp.Backend.Controllers.Mastodon.Schemas;
public class FilterSchemas
{
public class CreateFilterRequest
{
[B(Name = "title")] [J("title")] [JR] public required string Title { get; set; }
[B(Name = "context")]
[J("context")]
[JR]
public required List<string> Context { get; set; }
[B(Name = "filter_action")]
[J("filter_action")]
[JR]
public required string Action { get; set; }
[B(Name = "expires_in")]
[J("expires_in")]
public long? ExpiresIn { get; set; }
[B(Name = "keywords_attributes")]
[J("keywords_attributes")]
public List<FilterKeywordsAttributes> Keywords { get; set; } = [];
}
public class UpdateFilterRequest : CreateFilterRequest
{
[B(Name = "keywords_attributes")]
[J("keywords_attributes")]
public new List<UpdateFilterKeywordsAttributes> Keywords { get; set; } = [];
}
public class FilterKeywordsAttributes
{
[B(Name = "keyword")]
[J("keyword")]
[JR]
public required string Keyword { get; set; }
[B(Name = "whole_word")]
[J("whole_word")]
public bool WholeWord { get; set; } = false;
}
public class UpdateFilterKeywordsAttributes : FilterKeywordsAttributes
{
[B(Name = "id")] [J("id")] public string? Id { get; set; }
[B(Name = "_destroy")] [J("_destroy")] public bool Destroy { get; set; } = false;
}
}

View file

@ -87,7 +87,7 @@ public class StatusController(
.FilterBlocked(user)
.FilterMuted(user)
.PrecomputeVisibilities(user)
.RenderAllForMastodonAsync(noteRenderer, user);
.RenderAllForMastodonAsync(noteRenderer, user, Filter.FilterContext.Threads);
var descendants = await db.NoteDescendants(id, maxDepth, maxDescendants)
.Where(p => !p.IsQuote || p.RenoteId != id)
@ -96,7 +96,7 @@ public class StatusController(
.FilterBlocked(user)
.FilterMuted(user)
.PrecomputeVisibilities(user)
.RenderAllForMastodonAsync(noteRenderer, user);
.RenderAllForMastodonAsync(noteRenderer, user, Filter.FilterContext.Threads);
var res = new StatusContext { Ancestors = ancestors, Descendants = descendants };
@ -483,7 +483,7 @@ public class StatusController(
var note = await db.Notes.IncludeCommonProperties().FirstOrDefaultAsync(p => p.Id == id && p.User == user) ??
throw GracefulException.RecordNotFound();
var res = await noteRenderer.RenderAsync(note, user, new NoteRenderer.NoteRendererDto { Source = true });
var res = await noteRenderer.RenderAsync(note, user, data: new NoteRenderer.NoteRendererDto { Source = true });
await noteSvc.DeleteNoteAsync(note);
return Ok(res);

View file

@ -42,7 +42,7 @@ public class TimelineController(DatabaseContext db, NoteRenderer noteRenderer, C
.FilterMuted(user)
.Paginate(query, ControllerContext)
.PrecomputeVisibilities(user)
.RenderAllForMastodonAsync(noteRenderer, user);
.RenderAllForMastodonAsync(noteRenderer, user, Filter.FilterContext.Home);
return Ok(res);
}
@ -64,7 +64,7 @@ public class TimelineController(DatabaseContext db, NoteRenderer noteRenderer, C
.FilterMuted(user)
.Paginate(query, ControllerContext)
.PrecomputeVisibilities(user)
.RenderAllForMastodonAsync(noteRenderer, user);
.RenderAllForMastodonAsync(noteRenderer, user, Filter.FilterContext.Public);
return Ok(res);
}
@ -86,7 +86,7 @@ public class TimelineController(DatabaseContext db, NoteRenderer noteRenderer, C
.FilterMuted(user)
.Paginate(query, ControllerContext)
.PrecomputeVisibilities(user)
.RenderAllForMastodonAsync(noteRenderer, user);
.RenderAllForMastodonAsync(noteRenderer, user, Filter.FilterContext.Public);
return Ok(res);
}
@ -108,7 +108,7 @@ public class TimelineController(DatabaseContext db, NoteRenderer noteRenderer, C
.FilterMuted(user)
.Paginate(query, ControllerContext)
.PrecomputeVisibilities(user)
.RenderAllForMastodonAsync(noteRenderer, user);
.RenderAllForMastodonAsync(noteRenderer, user, Filter.FilterContext.Lists);
return Ok(res);
}

View file

@ -88,6 +88,7 @@ public class DatabaseContext(DbContextOptions<DatabaseContext> options)
public virtual DbSet<CacheEntry> CacheStore { get; init; } = null!;
public virtual DbSet<Job> Jobs { get; init; } = null!;
public virtual DbSet<Worker> Workers { get; init; } = null!;
public virtual DbSet<Filter> Filters { get; init; } = null!;
public virtual DbSet<DataProtectionKey> DataProtectionKeys { get; init; } = null!;
public static NpgsqlDataSource GetDataSource(Config.DatabaseSection? config)
@ -117,6 +118,8 @@ public class DatabaseContext(DbContextOptions<DatabaseContext> options)
dataSourceBuilder.MapEnum<Marker.MarkerType>();
dataSourceBuilder.MapEnum<PushSubscription.PushPolicy>();
dataSourceBuilder.MapEnum<Job.JobStatus>();
dataSourceBuilder.MapEnum<Filter.FilterContext>();
dataSourceBuilder.MapEnum<Filter.FilterAction>();
dataSourceBuilder.EnableDynamicJson();
@ -142,6 +145,8 @@ public class DatabaseContext(DbContextOptions<DatabaseContext> options)
.HasPostgresEnum<Marker.MarkerType>()
.HasPostgresEnum<PushSubscription.PushPolicy>()
.HasPostgresEnum<Job.JobStatus>()
.HasPostgresEnum<Filter.FilterContext>()
.HasPostgresEnum<Filter.FilterAction>()
.HasPostgresExtension("pg_trgm");
modelBuilder
@ -976,89 +981,6 @@ public class DatabaseContext(DbContextOptions<DatabaseContext> options)
modelBuilder.Entity<UsedUsername>();
modelBuilder.Entity<User>(entity =>
{
entity.Property(e => e.AlsoKnownAs).HasComment("URIs the user is known as too");
entity.Property(e => e.AvatarBlurhash).HasComment("The blurhash of the avatar DriveFile");
entity.Property(e => e.AvatarId).HasComment("The ID of avatar DriveFile.");
entity.Property(e => e.AvatarUrl).HasComment("The URL of the avatar DriveFile");
entity.Property(e => e.BannerBlurhash).HasComment("The blurhash of the banner DriveFile");
entity.Property(e => e.BannerId).HasComment("The ID of banner DriveFile.");
entity.Property(e => e.BannerUrl).HasComment("The URL of the banner DriveFile");
entity.Property(e => e.CreatedAt).HasComment("The created date of the User.");
entity.Property(e => e.DriveCapacityOverrideMb).HasComment("Overrides user drive capacity limit");
entity.Property(e => e.Emojis).HasDefaultValueSql("'{}'::character varying[]");
entity.Property(e => e.Featured)
.HasComment("The featured URL of the User. It will be null if the origin of the user is local.");
entity.Property(e => e.FollowersCount)
.HasDefaultValue(0)
.HasComment("The count of followers.");
entity.Property(e => e.FollowersUri)
.HasComment("The URI of the user Follower Collection. It will be null if the origin of the user is local.");
entity.Property(e => e.FollowingCount)
.HasDefaultValue(0)
.HasComment("The count of following.");
entity.Property(e => e.HideOnlineStatus).HasDefaultValue(false);
entity.Property(e => e.Host)
.HasComment("The host of the User. It will be null if the origin of the user is local.");
entity.Property(e => e.Inbox)
.HasComment("The inbox URL of the User. It will be null if the origin of the user is local.");
entity.Property(e => e.IsAdmin)
.HasDefaultValue(false)
.HasComment("Whether the User is the admin.");
entity.Property(e => e.IsBot)
.HasDefaultValue(false)
.HasComment("Whether the User is a bot.");
entity.Property(e => e.IsCat)
.HasDefaultValue(false)
.HasComment("Whether the User is a cat.");
entity.Property(e => e.IsDeleted)
.HasDefaultValue(false)
.HasComment("Whether the User is deleted.");
entity.Property(e => e.IsExplorable)
.HasDefaultValue(true)
.HasComment("Whether the User is explorable.");
entity.Property(e => e.IsLocked)
.HasDefaultValue(false)
.HasComment("Whether the User is locked.");
entity.Property(e => e.IsModerator)
.HasDefaultValue(false)
.HasComment("Whether the User is a moderator.");
entity.Property(e => e.IsSilenced)
.HasDefaultValue(false)
.HasComment("Whether the User is silenced.");
entity.Property(e => e.IsSuspended)
.HasDefaultValue(false)
.HasComment("Whether the User is suspended.");
entity.Property(e => e.MovedToUri).HasComment("The URI of the new account of the User");
entity.Property(e => e.DisplayName).HasComment("The name of the User.");
entity.Property(e => e.NotesCount)
.HasDefaultValue(0)
.HasComment("The count of notes.");
entity.Property(e => e.SharedInbox)
.HasComment("The sharedInbox URL of the User. It will be null if the origin of the user is local.");
entity.Property(e => e.SpeakAsCat)
.HasDefaultValue(true)
.HasComment("Whether to speak as a cat if isCat.");
entity.Property(e => e.Tags).HasDefaultValueSql("'{}'::character varying[]");
entity.Property(e => e.Token)
.IsFixedLength()
.HasComment("The native access token of the User. It will be null if the origin of the user is local.");
entity.Property(e => e.UpdatedAt).HasComment("The updated date of the User.");
entity.Property(e => e.Uri)
.HasComment("The URI of the User. It will be null if the origin of the user is local.");
entity.Property(e => e.Username).HasComment("The username of the User.");
entity.Property(e => e.UsernameLower).HasComment("The username (lowercased) of the User.");
entity.HasOne(d => d.Avatar)
.WithOne(p => p.UserAvatar)
.OnDelete(DeleteBehavior.SetNull);
entity.HasOne(d => d.Banner)
.WithOne(p => p.UserBanner)
.OnDelete(DeleteBehavior.SetNull);
});
modelBuilder.Entity<UserGroup>(entity =>
{
entity.Property(e => e.CreatedAt).HasComment("The created date of the UserGroup.");
@ -1256,6 +1178,8 @@ public class DatabaseContext(DbContextOptions<DatabaseContext> options)
entity.Property(e => e.QueuedAt).HasDefaultValueSql("now()");
entity.HasOne<Worker>().WithMany().HasForeignKey(d => d.WorkerId).OnDelete(DeleteBehavior.SetNull);
});
modelBuilder.ApplyConfigurationsFromAssembly(typeof(DatabaseContext).Assembly);
}
public async Task ReloadEntityAsync(object entity)

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,102 @@
using System;
using System.Collections.Generic;
using Iceshrimp.Backend.Core.Database.Tables;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Iceshrimp.Backend.Core.Database.Migrations
{
/// <inheritdoc />
public partial class AddFilterTable : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterDatabase()
.Annotation("Npgsql:Enum:antenna_src_enum", "home,all,users,list,group,instances")
.Annotation("Npgsql:Enum:filter_action_enum", "warn,hide")
.Annotation("Npgsql:Enum:filter_context_enum", "home,lists,threads,notifications,accounts,public")
.Annotation("Npgsql:Enum:job_status", "queued,delayed,running,completed,failed")
.Annotation("Npgsql:Enum:marker_type_enum", "home,notifications")
.Annotation("Npgsql:Enum:note_visibility_enum", "public,home,followers,specified")
.Annotation("Npgsql:Enum:notification_type_enum", "follow,mention,reply,renote,quote,like,reaction,pollVote,pollEnded,receiveFollowRequest,followRequestAccepted,groupInvited,app,edit,bite")
.Annotation("Npgsql:Enum:page_visibility_enum", "public,followers,specified")
.Annotation("Npgsql:Enum:push_subscription_policy_enum", "all,followed,follower,none")
.Annotation("Npgsql:Enum:relay_status_enum", "requesting,accepted,rejected")
.Annotation("Npgsql:Enum:user_profile_ffvisibility_enum", "public,followers,private")
.Annotation("Npgsql:PostgresExtension:pg_trgm", ",,")
.OldAnnotation("Npgsql:Enum:antenna_src_enum", "home,all,users,list,group,instances")
.OldAnnotation("Npgsql:Enum:job_status", "queued,delayed,running,completed,failed")
.OldAnnotation("Npgsql:Enum:marker_type_enum", "home,notifications")
.OldAnnotation("Npgsql:Enum:note_visibility_enum", "public,home,followers,specified")
.OldAnnotation("Npgsql:Enum:notification_type_enum", "follow,mention,reply,renote,quote,like,reaction,pollVote,pollEnded,receiveFollowRequest,followRequestAccepted,groupInvited,app,edit,bite")
.OldAnnotation("Npgsql:Enum:page_visibility_enum", "public,followers,specified")
.OldAnnotation("Npgsql:Enum:push_subscription_policy_enum", "all,followed,follower,none")
.OldAnnotation("Npgsql:Enum:relay_status_enum", "requesting,accepted,rejected")
.OldAnnotation("Npgsql:Enum:user_profile_ffvisibility_enum", "public,followers,private")
.OldAnnotation("Npgsql:PostgresExtension:pg_trgm", ",,");
migrationBuilder.CreateTable(
name: "filter",
columns: table => new
{
id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
user_id = table.Column<string>(type: "character varying(32)", nullable: true),
name = table.Column<string>(type: "text", nullable: false),
expiry = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
keywords = table.Column<List<string>>(type: "text[]", nullable: false, defaultValueSql: "'{}'::varchar[]"),
contexts = table.Column<List<Filter.FilterContext>>(type: "filter_context_enum[]", nullable: false, defaultValueSql: "'{}'::public.filter_context_enum[]"),
action = table.Column<Filter.FilterAction>(type: "filter_action_enum", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_filter", x => x.id);
table.ForeignKey(
name: "FK_filter_user_user_id",
column: x => x.user_id,
principalTable: "user",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_filter_user_id",
table: "filter",
column: "user_id");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "filter");
migrationBuilder.AlterDatabase()
.Annotation("Npgsql:Enum:antenna_src_enum", "home,all,users,list,group,instances")
.Annotation("Npgsql:Enum:job_status", "queued,delayed,running,completed,failed")
.Annotation("Npgsql:Enum:marker_type_enum", "home,notifications")
.Annotation("Npgsql:Enum:note_visibility_enum", "public,home,followers,specified")
.Annotation("Npgsql:Enum:notification_type_enum", "follow,mention,reply,renote,quote,like,reaction,pollVote,pollEnded,receiveFollowRequest,followRequestAccepted,groupInvited,app,edit,bite")
.Annotation("Npgsql:Enum:page_visibility_enum", "public,followers,specified")
.Annotation("Npgsql:Enum:push_subscription_policy_enum", "all,followed,follower,none")
.Annotation("Npgsql:Enum:relay_status_enum", "requesting,accepted,rejected")
.Annotation("Npgsql:Enum:user_profile_ffvisibility_enum", "public,followers,private")
.Annotation("Npgsql:PostgresExtension:pg_trgm", ",,")
.OldAnnotation("Npgsql:Enum:antenna_src_enum", "home,all,users,list,group,instances")
.OldAnnotation("Npgsql:Enum:filter_action_enum", "warn,hide")
.OldAnnotation("Npgsql:Enum:filter_context_enum", "home,lists,threads,notifications,accounts,public")
.OldAnnotation("Npgsql:Enum:job_status", "queued,delayed,running,completed,failed")
.OldAnnotation("Npgsql:Enum:marker_type_enum", "home,notifications")
.OldAnnotation("Npgsql:Enum:note_visibility_enum", "public,home,followers,specified")
.OldAnnotation("Npgsql:Enum:notification_type_enum", "follow,mention,reply,renote,quote,like,reaction,pollVote,pollEnded,receiveFollowRequest,followRequestAccepted,groupInvited,app,edit,bite")
.OldAnnotation("Npgsql:Enum:page_visibility_enum", "public,followers,specified")
.OldAnnotation("Npgsql:Enum:push_subscription_policy_enum", "all,followed,follower,none")
.OldAnnotation("Npgsql:Enum:relay_status_enum", "requesting,accepted,rejected")
.OldAnnotation("Npgsql:Enum:user_profile_ffvisibility_enum", "public,followers,private")
.OldAnnotation("Npgsql:PostgresExtension:pg_trgm", ",,");
}
}
}

View file

@ -23,6 +23,8 @@ namespace Iceshrimp.Backend.Core.Database.Migrations
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "antenna_src_enum", new[] { "home", "all", "users", "list", "group", "instances" });
NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "filter_action_enum", new[] { "warn", "hide" });
NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "filter_context_enum", new[] { "home", "lists", "threads", "notifications", "accounts", "public" });
NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "job_status", new[] { "queued", "delayed", "running", "completed", "failed" });
NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "marker_type_enum", new[] { "home", "notifications" });
NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "note_visibility_enum", new[] { "public", "home", "followers", "specified" });
@ -1044,6 +1046,52 @@ namespace Iceshrimp.Backend.Core.Database.Migrations
b.ToTable("emoji");
});
modelBuilder.Entity("Iceshrimp.Backend.Core.Database.Tables.Filter", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<Filter.FilterAction>("Action")
.HasColumnType("filter_action_enum")
.HasColumnName("action");
b.Property<List<Filter.FilterContext>>("Contexts")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("filter_context_enum[]")
.HasColumnName("contexts")
.HasDefaultValueSql("'{}'::public.filter_context_enum[]");
b.Property<DateTime?>("Expiry")
.HasColumnType("timestamp with time zone")
.HasColumnName("expiry");
b.Property<List<string>>("Keywords")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("text[]")
.HasColumnName("keywords")
.HasDefaultValueSql("'{}'::varchar[]");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text")
.HasColumnName("name");
b.Property<string>("user_id")
.HasColumnType("character varying(32)");
b.HasKey("Id");
b.HasIndex("user_id");
b.ToTable("filter");
});
modelBuilder.Entity("Iceshrimp.Backend.Core.Database.Tables.FollowRequest", b =>
{
b.Property<string>("Id")
@ -5047,6 +5095,16 @@ namespace Iceshrimp.Backend.Core.Database.Migrations
b.Navigation("User");
});
modelBuilder.Entity("Iceshrimp.Backend.Core.Database.Tables.Filter", b =>
{
b.HasOne("Iceshrimp.Backend.Core.Database.Tables.User", "User")
.WithMany("Filters")
.HasForeignKey("user_id")
.OnDelete(DeleteBehavior.Cascade);
b.Navigation("User");
});
modelBuilder.Entity("Iceshrimp.Backend.Core.Database.Tables.FollowRequest", b =>
{
b.HasOne("Iceshrimp.Backend.Core.Database.Tables.User", "Followee")
@ -5901,6 +5959,8 @@ namespace Iceshrimp.Backend.Core.Database.Migrations
b.Navigation("DriveFolders");
b.Navigation("Filters");
b.Navigation("GalleryLikes");
b.Navigation("GalleryPosts");

View file

@ -0,0 +1,57 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using NpgsqlTypes;
namespace Iceshrimp.Backend.Core.Database.Tables;
[Table("filter")]
[Index("user_id")]
public class Filter
{
[PgName("filter_context_enum")]
public enum FilterContext
{
[PgName("home")] Home,
[PgName("lists")] Lists,
[PgName("threads")] Threads,
[PgName("notifications")] Notifications,
[PgName("accounts")] Accounts,
[PgName("public")] Public,
}
[PgName("filter_action_enum")]
public enum FilterAction
{
[PgName("warn")] Warn,
[PgName("hide")] Hide,
}
[Key, DatabaseGenerated(DatabaseGeneratedOption.Identity)]
[Column("id")]
public long Id { get; set; }
[InverseProperty(nameof(User.Filters))]
public User User { get; set; } = null!;
[Column("name")] public string Name { get; set; } = null!;
[Column("expiry")] public DateTime? Expiry { get; set; }
[Column("keywords")] public List<string> Keywords { get; set; } = [];
[Column("contexts")] public List<FilterContext> Contexts { get; set; } = [];
[Column("action")] public FilterAction Action { get; set; }
}
public class FilterEntityTypeConfiguration : IEntityTypeConfiguration<Filter>
{
public void Configure(EntityTypeBuilder<Filter> entity)
{
entity.HasOne(p => p.User)
.WithMany(p => p.Filters)
.OnDelete(DeleteBehavior.Cascade)
.HasForeignKey("user_id");
entity.Property(p => p.Keywords).HasDefaultValueSql("'{}'::varchar[]");
entity.Property(p => p.Contexts).HasDefaultValueSql("'{}'::public.filter_context_enum[]");
}
}

View file

@ -4,6 +4,7 @@ using EntityFrameworkCore.Projectables;
using Iceshrimp.Backend.Core.Configuration;
using Iceshrimp.Backend.Core.Helpers;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Iceshrimp.Backend.Core.Database.Tables;
@ -482,6 +483,9 @@ public class User : IEntity
[InverseProperty(nameof(Webhook.User))]
public virtual ICollection<Webhook> Webhooks { get; set; } = new List<Webhook>();
[InverseProperty(nameof(Filter.User))]
public virtual ICollection<Filter> Filters { get; set; } = new List<Filter>();
[NotMapped] public bool? PrecomputedIsBlocking { get; set; }
[NotMapped] public bool? PrecomputedIsBlockedBy { get; set; }
@ -620,3 +624,89 @@ public class User : IEntity
public string GetIdenticonUrl(string webDomain) => $"https://{webDomain}/identicon/{Id}";
}
public class UserEntityTypeConfiguration : IEntityTypeConfiguration<User>
{
public void Configure(EntityTypeBuilder<User> entity)
{
entity.Property(e => e.AlsoKnownAs).HasComment("URIs the user is known as too");
entity.Property(e => e.AvatarBlurhash).HasComment("The blurhash of the avatar DriveFile");
entity.Property(e => e.AvatarId).HasComment("The ID of avatar DriveFile.");
entity.Property(e => e.AvatarUrl).HasComment("The URL of the avatar DriveFile");
entity.Property(e => e.BannerBlurhash).HasComment("The blurhash of the banner DriveFile");
entity.Property(e => e.BannerId).HasComment("The ID of banner DriveFile.");
entity.Property(e => e.BannerUrl).HasComment("The URL of the banner DriveFile");
entity.Property(e => e.CreatedAt).HasComment("The created date of the User.");
entity.Property(e => e.DriveCapacityOverrideMb).HasComment("Overrides user drive capacity limit");
entity.Property(e => e.Emojis).HasDefaultValueSql("'{}'::character varying[]");
entity.Property(e => e.Featured)
.HasComment("The featured URL of the User. It will be null if the origin of the user is local.");
entity.Property(e => e.FollowersCount)
.HasDefaultValue(0)
.HasComment("The count of followers.");
entity.Property(e => e.FollowersUri)
.HasComment("The URI of the user Follower Collection. It will be null if the origin of the user is local.");
entity.Property(e => e.FollowingCount)
.HasDefaultValue(0)
.HasComment("The count of following.");
entity.Property(e => e.HideOnlineStatus).HasDefaultValue(false);
entity.Property(e => e.Host)
.HasComment("The host of the User. It will be null if the origin of the user is local.");
entity.Property(e => e.Inbox)
.HasComment("The inbox URL of the User. It will be null if the origin of the user is local.");
entity.Property(e => e.IsAdmin)
.HasDefaultValue(false)
.HasComment("Whether the User is the admin.");
entity.Property(e => e.IsBot)
.HasDefaultValue(false)
.HasComment("Whether the User is a bot.");
entity.Property(e => e.IsCat)
.HasDefaultValue(false)
.HasComment("Whether the User is a cat.");
entity.Property(e => e.IsDeleted)
.HasDefaultValue(false)
.HasComment("Whether the User is deleted.");
entity.Property(e => e.IsExplorable)
.HasDefaultValue(true)
.HasComment("Whether the User is explorable.");
entity.Property(e => e.IsLocked)
.HasDefaultValue(false)
.HasComment("Whether the User is locked.");
entity.Property(e => e.IsModerator)
.HasDefaultValue(false)
.HasComment("Whether the User is a moderator.");
entity.Property(e => e.IsSilenced)
.HasDefaultValue(false)
.HasComment("Whether the User is silenced.");
entity.Property(e => e.IsSuspended)
.HasDefaultValue(false)
.HasComment("Whether the User is suspended.");
entity.Property(e => e.MovedToUri).HasComment("The URI of the new account of the User");
entity.Property(e => e.DisplayName).HasComment("The name of the User.");
entity.Property(e => e.NotesCount)
.HasDefaultValue(0)
.HasComment("The count of notes.");
entity.Property(e => e.SharedInbox)
.HasComment("The sharedInbox URL of the User. It will be null if the origin of the user is local.");
entity.Property(e => e.SpeakAsCat)
.HasDefaultValue(true)
.HasComment("Whether to speak as a cat if isCat.");
entity.Property(e => e.Tags).HasDefaultValueSql("'{}'::character varying[]");
entity.Property(e => e.Token)
.IsFixedLength()
.HasComment("The native access token of the User. It will be null if the origin of the user is local.");
entity.Property(e => e.UpdatedAt).HasComment("The updated date of the User.");
entity.Property(e => e.Uri)
.HasComment("The URI of the User. It will be null if the origin of the user is local.");
entity.Property(e => e.Username).HasComment("The username of the User.");
entity.Property(e => e.UsernameLower).HasComment("The username (lowercased) of the User.");
entity.HasOne(d => d.Avatar)
.WithOne(p => p.UserAvatar)
.OnDelete(DeleteBehavior.SetNull);
entity.HasOne(d => d.Banner)
.WithOne(p => p.UserBanner)
.OnDelete(DeleteBehavior.SetNull);
}
}

View file

@ -457,13 +457,13 @@ public static class QueryableExtensions
}
public static async Task<List<StatusEntity>> RenderAllForMastodonAsync(
this IQueryable<Note> notes, NoteRenderer renderer, User? user
this IQueryable<Note> notes, NoteRenderer renderer, User? user, Filter.FilterContext? filterContext = null
)
{
var list = (await notes.ToListAsync())
.EnforceRenoteReplyVisibility()
.ToList();
return (await renderer.RenderManyAsync(list, user)).ToList();
return (await renderer.RenderManyAsync(list, user, filterContext)).ToList();
}
public static async Task<List<AccountEntity>> RenderAllForMastodonAsync(

View file

@ -0,0 +1,58 @@
using System.Text.RegularExpressions;
using Iceshrimp.Backend.Core.Database.Tables;
namespace Iceshrimp.Backend.Core.Helpers;
public static class FilterHelper
{
public static (Filter filter, string keyword)? IsFiltered(IEnumerable<Note?> notes, List<Filter> filters)
{
if (filters.Count == 0) return null;
foreach (var note in notes.OfType<Note>())
{
var match = IsFiltered(note, filters);
if (match != null) return match;
}
return null;
}
private static (Filter filter, string keyword)? IsFiltered(Note note, List<Filter> filters)
{
if (filters.Count == 0) return null;
if (note.Text == null && note.Cw == null) return null;
foreach (var filter in filters)
{
var match = IsFiltered(note, filter);
if (match != null) return (filter, match);
}
return null;
}
private static string? IsFiltered(Note note, Filter filter)
{
foreach (var keyword in filter.Keywords)
{
if (keyword.StartsWith('"') && keyword.EndsWith('"'))
{
var pattern = $@"\b{EfHelpers.EscapeRegexQuery(keyword[1..^1])}\b";
var regex = new Regex(pattern, RegexOptions.IgnoreCase, TimeSpan.FromMilliseconds(10));
if (note.Text != null && regex.IsMatch(note.Text))
return keyword;
if (note.Cw != null && regex.IsMatch(note.Cw))
return keyword;
}
else if ((note.Text != null && note.Text.Contains(keyword, StringComparison.InvariantCultureIgnoreCase)) ||
note.Cw != null && note.Cw.Contains(keyword, StringComparison.InvariantCultureIgnoreCase))
{
return keyword;
}
}
return null;
}
}

View file

@ -36,6 +36,9 @@ public class BackgroundTaskQueue()
case MuteExpiryJobData muteExpiryJob:
await ProcessMuteExpiry(muteExpiryJob, scope, token);
break;
case FilterExpiryJobData filterExpiryJob:
await ProcessFilterExpiry(filterExpiryJob, scope, token);
break;
}
}
@ -192,11 +195,28 @@ public class BackgroundTaskQueue()
var eventSvc = scope.GetRequiredService<EventService>();
eventSvc.RaiseUserUnmuted(null, muting.Muter, muting.Mutee);
}
private static async Task ProcessFilterExpiry(
FilterExpiryJobData jobData,
IServiceProvider scope,
CancellationToken token
)
{
var db = scope.GetRequiredService<DatabaseContext>();
var filter = await db.Filters.FirstOrDefaultAsync(p => p.Id == jobData.FilterId, token);
if (filter is not { Expiry: not null }) return;
if (filter.Expiry > DateTime.UtcNow + TimeSpan.FromSeconds(30)) return;
db.Remove(filter);
await db.SaveChangesAsync(token);
}
}
[JsonDerivedType(typeof(DriveFileDeleteJobData), "driveFileDelete")]
[JsonDerivedType(typeof(PollExpiryJobData), "pollExpiry")]
[JsonDerivedType(typeof(MuteExpiryJobData), "muteExpiry")]
[JsonDerivedType(typeof(FilterExpiryJobData), "filterExpiry")]
public abstract class BackgroundTaskJobData;
public class DriveFileDeleteJobData : BackgroundTaskJobData
@ -214,3 +234,8 @@ public class MuteExpiryJobData : BackgroundTaskJobData
{
[JR] [J("muteId")] public required string MuteId { get; set; }
}
public class FilterExpiryJobData : BackgroundTaskJobData
{
[JR] [J("filterId")] public required long FilterId { get; set; }
}

View file

@ -0,0 +1,21 @@
using System.Diagnostics.CodeAnalysis;
using Iceshrimp.Backend.Core.Database;
using Iceshrimp.Backend.Core.Services;
using Microsoft.EntityFrameworkCore;
namespace Iceshrimp.Backend.Core.Tasks;
[SuppressMessage("ReSharper", "UnusedType.Global", Justification = "Instantiated at runtime by CronService")]
public class FilterExpiryTask : ICronTask
{
public async Task Invoke(IServiceProvider provider)
{
var db = provider.GetRequiredService<DatabaseContext>();
await db.Filters.Where(p => p.Expiry != null && p.Expiry < DateTime.UtcNow - TimeSpan.FromMinutes(5))
.ExecuteDeleteAsync();
}
// Midnight
public TimeSpan Trigger => TimeSpan.Zero;
public CronTaskType Type => CronTaskType.Daily;
}