[backend/core] Support note filters (ISH-97)
This commit is contained in:
parent
bde33e09bf
commit
62dde50af2
22 changed files with 6971 additions and 111 deletions
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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
|
||||
};
|
||||
|
||||
|
|
279
Iceshrimp.Backend/Controllers/Mastodon/FilterController.cs
Normal file
279
Iceshrimp.Backend/Controllers/Mastodon/FilterController.cs
Normal 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?)
|
||||
}
|
|
@ -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"
|
||||
};
|
||||
}
|
||||
}
|
|
@ -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])
|
||||
|
@ -261,7 +283,7 @@ public class NoteRenderer(
|
|||
.Cast<Note>()
|
||||
.DistinctBy(p => p.Id)
|
||||
.ToList();
|
||||
|
||||
|
||||
if (noteList.Count == 0) return [];
|
||||
|
||||
var data = new NoteRendererDto
|
||||
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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; }
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -41,13 +41,13 @@ public class StatusEntity : IEntity
|
|||
|
||||
[J("poll")] public required PollEntity? Poll { 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("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
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
@ -975,90 +980,7 @@ 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)
|
||||
|
|
6090
Iceshrimp.Backend/Core/Database/Migrations/20240331190007_AddFilterTable.Designer.cs
generated
Normal file
6090
Iceshrimp.Backend/Core/Database/Migrations/20240331190007_AddFilterTable.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
|
@ -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", ",,");
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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");
|
||||
|
|
57
Iceshrimp.Backend/Core/Database/Tables/Filter.cs
Normal file
57
Iceshrimp.Backend/Core/Database/Tables/Filter.cs
Normal 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[]");
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
|
||||
|
@ -481,6 +482,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; }
|
||||
|
@ -619,4 +623,90 @@ public class User : IEntity
|
|||
: throw new Exception("Cannot access PublicUrl for remote user");
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
|
@ -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(
|
||||
|
|
58
Iceshrimp.Backend/Core/Helpers/FilterHelper.cs
Normal file
58
Iceshrimp.Backend/Core/Helpers/FilterHelper.cs
Normal 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;
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
@ -213,4 +233,9 @@ public class PollExpiryJobData : BackgroundTaskJobData
|
|||
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; }
|
||||
}
|
21
Iceshrimp.Backend/Core/Tasks/FilterExpiryTask.cs
Normal file
21
Iceshrimp.Backend/Core/Tasks/FilterExpiryTask.cs
Normal 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;
|
||||
}
|
Loading…
Add table
Reference in a new issue