Filters: words and posts a persona would rather not see
Mastodon's v2 filters replace the empty stubs: a filter's title, contexts, action (warn, hide, blur) and expiry, its keywords (whole words or not, taken as JSON objects, listed or numbered form fields, with id and _destroy on update) and its statuses, each with their own endpoints; the v1 API is the same filters seen keyword by keyword. Every status a persona reads carries the filters it matches in `filtered` (a boost as what it boosts), matched as Mastodon matches: warning, title, text, poll options and media descriptions. Clients apply context and action. Filters never federate, and go with a deleted persona. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
This commit is contained in:
1 parent
b710493701
commit
5b3e456e09
12 files changed
+759
-13
No files matched your search
@@ -0,0 +1,362 @@
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Api.Mastodon.Entities;
|
||||
using PrivaPub.Api.Mastodon.Infrastructure;
|
||||
using PrivaPub.Domain.Social;
|
||||
using PrivaPub.Models.Social;
|
||||
using PrivaPub.StaticServices;
|
||||
|
||||
namespace PrivaPub.Api.Mastodon.Controllers
|
||||
{
|
||||
// Mastodon's filters (owner decision 2026-10-04, back from the cut list): v2 filters with their keywords and statuses,
|
||||
// and the v1 API, whose filter is one keyword of a v2 filter. Statuses carry what they match in `filtered`; clients
|
||||
// apply the context and the action. Nothing here leaves the server.
|
||||
public class FiltersController : MastodonController
|
||||
{
|
||||
const int MaxFilters = 100;
|
||||
const int MaxEntries = 100;
|
||||
const int MaxKeywordLength = 100;
|
||||
static readonly Regex Attribute = new(@"^keywords_attributes\[(\d*)\]\[(\w+)\]$", RegexOptions.Compiled);
|
||||
|
||||
readonly DbEntities _dbEntities;
|
||||
|
||||
public FiltersController(DbEntities dbEntities)
|
||||
{
|
||||
_dbEntities = dbEntities;
|
||||
}
|
||||
|
||||
static string NewId() => ObjectId.GenerateNewId().ToString();
|
||||
|
||||
Task<PersonaFilter> Mine(string id, CancellationToken token) =>
|
||||
DB.Default.Find<PersonaFilter>().Match(f => f.ID == id && f.AvatarId == MyId).ExecuteFirstAsync(token);
|
||||
|
||||
Task<PersonaFilter> WithKeyword(string keywordId, CancellationToken token) =>
|
||||
DB.Default.Find<PersonaFilter>().Match(f => f.AvatarId == MyId && f.Keywords.Any(k => k.Id == keywordId)).ExecuteFirstAsync(token);
|
||||
|
||||
Task<PersonaFilter> WithStatus(string statusFilterId, CancellationToken token) =>
|
||||
DB.Default.Find<PersonaFilter>().Match(f => f.AvatarId == MyId && f.Statuses.Any(s => s.Id == statusFilterId)).ExecuteFirstAsync(token);
|
||||
|
||||
static JsonResult Invalid(string message) => Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: " + message);
|
||||
|
||||
// title, context[], filter_action, expires_in: what a create or an update sets; an error message when one is wrong
|
||||
string Apply(PersonaFilter filter, bool creating)
|
||||
{
|
||||
var title = Params.Get("title")?.Trim();
|
||||
if (title != default || creating)
|
||||
{
|
||||
if (string.IsNullOrEmpty(title) || title.Length > 200)
|
||||
return "Title can't be blank";
|
||||
filter.Title = title;
|
||||
}
|
||||
if (Params.Has("context") || creating)
|
||||
{
|
||||
var context = Params.List("context").Distinct().ToList();
|
||||
if (context.Count == 0 || context.Any(c => !FilterContexts.IsValid(c)))
|
||||
return "Context can't be blank";
|
||||
filter.Context = context;
|
||||
}
|
||||
if (Params.Get("filter_action") is { } action)
|
||||
{
|
||||
if (!FilterActions.IsValid(action))
|
||||
return "Filter action is not included in the list";
|
||||
filter.Action = action;
|
||||
}
|
||||
if (Params.Has("expires_in"))
|
||||
filter.ExpiresAt = Params.Int("expires_in") is > 0 and var seconds ? DateTime.UtcNow.AddSeconds(seconds) : default;
|
||||
return default;
|
||||
}
|
||||
|
||||
// keywords_attributes, as Rails takes them: a JSON array of objects, or form fields [][keyword], [][whole_word], [][id],
|
||||
// [][_destroy], or numbered [0][keyword]
|
||||
List<Dictionary<string, string>> KeywordAttributes()
|
||||
{
|
||||
if (Params.Json is { ValueKind: System.Text.Json.JsonValueKind.Object } body
|
||||
&& body.TryGetProperty("keywords_attributes", out var array) && array.ValueKind == System.Text.Json.JsonValueKind.Array)
|
||||
return array.EnumerateArray().Where(o => o.ValueKind == System.Text.Json.JsonValueKind.Object)
|
||||
.Select(o => o.EnumerateObject().ToDictionary(p => p.Name, p => p.Value.ValueKind switch
|
||||
{
|
||||
System.Text.Json.JsonValueKind.String => p.Value.GetString(),
|
||||
System.Text.Json.JsonValueKind.True => "true",
|
||||
System.Text.Json.JsonValueKind.False => "false",
|
||||
_ => p.Value.ToString()
|
||||
}))
|
||||
.ToList();
|
||||
var numbered = new SortedDictionary<int, Dictionary<string, string>>();
|
||||
var listed = new Dictionary<string, IReadOnlyList<string>>();
|
||||
foreach (var name in Params.Names)
|
||||
{
|
||||
var match = Attribute.Match(name);
|
||||
if (!match.Success)
|
||||
continue;
|
||||
if (match.Groups[1].Value.Length == 0)
|
||||
listed[match.Groups[2].Value] = Params.List(name);
|
||||
else
|
||||
{
|
||||
var index = int.Parse(match.Groups[1].Value);
|
||||
if (!numbered.TryGetValue(index, out var item))
|
||||
numbered[index] = item = new Dictionary<string, string>();
|
||||
item[match.Groups[2].Value] = Params.Get(name);
|
||||
}
|
||||
}
|
||||
var items = numbered.Values.ToList();
|
||||
var count = listed.Count == 0 ? 0 : listed.Values.Max(v => v.Count);
|
||||
for (var i = 0; i < count; i++)
|
||||
items.Add(listed.Where(kv => i < kv.Value.Count).ToDictionary(kv => kv.Key, kv => kv.Value[i]));
|
||||
return items;
|
||||
}
|
||||
|
||||
static bool IsTrue(string value) => value?.ToLowerInvariant() is "true" or "1" or "on" or "yes";
|
||||
|
||||
// adds, changes or removes keywords; an error message when one is wrong
|
||||
static string ApplyKeywords(PersonaFilter filter, List<Dictionary<string, string>> items)
|
||||
{
|
||||
foreach (var item in items)
|
||||
{
|
||||
var id = item.GetValueOrDefault("id");
|
||||
var existing = id == default ? default : filter.Keywords.FirstOrDefault(k => k.Id == id);
|
||||
if (existing != default && IsTrue(item.GetValueOrDefault("_destroy")))
|
||||
{
|
||||
filter.Keywords.Remove(existing);
|
||||
continue;
|
||||
}
|
||||
var keyword = item.GetValueOrDefault("keyword")?.Trim();
|
||||
if (existing == default && string.IsNullOrEmpty(keyword))
|
||||
return "Keyword can't be blank";
|
||||
if (keyword?.Length > MaxKeywordLength)
|
||||
return "Keyword is too long";
|
||||
var target = existing ?? new FilterKeyword { Id = NewId() };
|
||||
if (!string.IsNullOrEmpty(keyword))
|
||||
target.Keyword = keyword;
|
||||
if (item.ContainsKey("whole_word"))
|
||||
target.WholeWord = IsTrue(item["whole_word"]);
|
||||
else if (existing == default)
|
||||
target.WholeWord = true;
|
||||
if (existing == default)
|
||||
filter.Keywords.Add(target);
|
||||
}
|
||||
return filter.Keywords.Count > MaxEntries ? "Too many keywords" : default;
|
||||
}
|
||||
|
||||
// -- v2
|
||||
|
||||
[HttpGet("/api/v2/filters"), Scope("read:filters")]
|
||||
public async Task<IActionResult> All(CancellationToken token) =>
|
||||
Json((await DB.Default.Find<PersonaFilter>().Match(f => f.AvatarId == MyId).Sort(f => f.ID, Order.Ascending).ExecuteAsync(token))
|
||||
.Select(Filters.View).ToList());
|
||||
|
||||
[HttpGet("/api/v2/filters/{id}"), Scope("read:filters")]
|
||||
public async Task<IActionResult> One(string id, CancellationToken token) =>
|
||||
await Mine(id, token) is { } filter ? Json(Filters.View(filter)) : NotFoundError();
|
||||
|
||||
[HttpPost("/api/v2/filters"), Scope("write:filters")]
|
||||
public async Task<IActionResult> Create(CancellationToken token)
|
||||
{
|
||||
if (await DB.Default.CountAsync<PersonaFilter>(f => f.AvatarId == MyId, token) >= MaxFilters)
|
||||
return Invalid("Too many filters");
|
||||
var filter = new PersonaFilter { AvatarId = MyId };
|
||||
if ((Apply(filter, creating: true) ?? ApplyKeywords(filter, KeywordAttributes())) is { } problem)
|
||||
return Invalid(problem);
|
||||
await DB.Default.SaveAsync(filter, token);
|
||||
return Json(Filters.View(filter));
|
||||
}
|
||||
|
||||
[HttpPut("/api/v2/filters/{id}"), Scope("write:filters")]
|
||||
public async Task<IActionResult> Update(string id, CancellationToken token)
|
||||
{
|
||||
if (await Mine(id, token) is not { } filter)
|
||||
return NotFoundError();
|
||||
if ((Apply(filter, creating: false) ?? ApplyKeywords(filter, KeywordAttributes())) is { } problem)
|
||||
return Invalid(problem);
|
||||
await DB.Default.SaveAsync(filter, token);
|
||||
return Json(Filters.View(filter));
|
||||
}
|
||||
|
||||
[HttpDelete("/api/v2/filters/{id}"), Scope("write:filters")]
|
||||
public async Task<IActionResult> Delete(string id, CancellationToken token)
|
||||
{
|
||||
if (await Mine(id, token) is not { } filter)
|
||||
return NotFoundError();
|
||||
await DB.Default.DeleteAsync<PersonaFilter>(filter.ID);
|
||||
return Json(new { });
|
||||
}
|
||||
|
||||
[HttpGet("/api/v2/filters/{id}/keywords"), Scope("read:filters")]
|
||||
public async Task<IActionResult> Keywords(string id, CancellationToken token) =>
|
||||
await Mine(id, token) is { } filter ? Json(filter.Keywords.Select(Filters.Keyword).ToList()) : NotFoundError();
|
||||
|
||||
[HttpPost("/api/v2/filters/{id}/keywords"), Scope("write:filters")]
|
||||
public async Task<IActionResult> AddKeyword(string id, CancellationToken token)
|
||||
{
|
||||
if (await Mine(id, token) is not { } filter)
|
||||
return NotFoundError();
|
||||
var item = new Dictionary<string, string> { ["keyword"] = Params.Get("keyword") };
|
||||
if (Params.Has("whole_word"))
|
||||
item["whole_word"] = Params.Get("whole_word");
|
||||
if (ApplyKeywords(filter, new List<Dictionary<string, string>> { item }) is { } problem)
|
||||
return Invalid(problem);
|
||||
await DB.Default.SaveAsync(filter, token);
|
||||
return Json(Filters.Keyword(filter.Keywords[^1]));
|
||||
}
|
||||
|
||||
[HttpGet("/api/v2/filters/keywords/{id}"), Scope("read:filters")]
|
||||
public async Task<IActionResult> Keyword(string id, CancellationToken token) =>
|
||||
await WithKeyword(id, token) is { } filter ? Json(Filters.Keyword(filter.Keywords.First(k => k.Id == id))) : NotFoundError();
|
||||
|
||||
[HttpPut("/api/v2/filters/keywords/{id}"), Scope("write:filters")]
|
||||
public async Task<IActionResult> UpdateKeyword(string id, CancellationToken token)
|
||||
{
|
||||
if (await WithKeyword(id, token) is not { } filter)
|
||||
return NotFoundError();
|
||||
var item = new Dictionary<string, string> { ["id"] = id, ["keyword"] = Params.Get("keyword") };
|
||||
if (Params.Has("whole_word"))
|
||||
item["whole_word"] = Params.Get("whole_word");
|
||||
if (ApplyKeywords(filter, new List<Dictionary<string, string>> { item }) is { } problem)
|
||||
return Invalid(problem);
|
||||
await DB.Default.SaveAsync(filter, token);
|
||||
return Json(Filters.Keyword(filter.Keywords.First(k => k.Id == id)));
|
||||
}
|
||||
|
||||
[HttpDelete("/api/v2/filters/keywords/{id}"), Scope("write:filters")]
|
||||
public async Task<IActionResult> DeleteKeyword(string id, CancellationToken token)
|
||||
{
|
||||
if (await WithKeyword(id, token) is not { } filter)
|
||||
return NotFoundError();
|
||||
filter.Keywords.RemoveAll(k => k.Id == id);
|
||||
await DB.Default.SaveAsync(filter, token);
|
||||
return Json(new { });
|
||||
}
|
||||
|
||||
[HttpGet("/api/v2/filters/{id}/statuses"), Scope("read:filters")]
|
||||
public async Task<IActionResult> Statuses(string id, CancellationToken token) =>
|
||||
await Mine(id, token) is { } filter ? Json(filter.Statuses.Select(Filters.Status).ToList()) : NotFoundError();
|
||||
|
||||
// a status the persona can see, once per filter
|
||||
[HttpPost("/api/v2/filters/{id}/statuses"), Scope("write:filters")]
|
||||
public async Task<IActionResult> AddStatus(string id, CancellationToken token)
|
||||
{
|
||||
if (await Mine(id, token) is not { } filter)
|
||||
return NotFoundError();
|
||||
var statusId = Params.Get("status_id");
|
||||
var post = string.IsNullOrEmpty(statusId) ? default : await _dbEntities.Posts.Match(p => p.ID == statusId).ExecuteFirstAsync(token);
|
||||
if (post == default || !await Domain.Privacy.VisibilityPolicy.CanSee(post, MyId, token))
|
||||
return NotFoundError();
|
||||
if (filter.Statuses.FirstOrDefault(s => s.PostId == statusId) is { } present)
|
||||
return Json(Filters.Status(present));
|
||||
if (filter.Statuses.Count >= MaxEntries)
|
||||
return Invalid("Too many statuses");
|
||||
var added = new FilterStatus { Id = NewId(), PostId = statusId };
|
||||
filter.Statuses.Add(added);
|
||||
await DB.Default.SaveAsync(filter, token);
|
||||
return Json(Filters.Status(added));
|
||||
}
|
||||
|
||||
[HttpGet("/api/v2/filters/statuses/{id}"), Scope("read:filters")]
|
||||
public async Task<IActionResult> Status(string id, CancellationToken token) =>
|
||||
await WithStatus(id, token) is { } filter ? Json(Filters.Status(filter.Statuses.First(s => s.Id == id))) : NotFoundError();
|
||||
|
||||
[HttpDelete("/api/v2/filters/statuses/{id}"), Scope("write:filters")]
|
||||
public async Task<IActionResult> DeleteStatus(string id, CancellationToken token)
|
||||
{
|
||||
if (await WithStatus(id, token) is not { } filter)
|
||||
return NotFoundError();
|
||||
filter.Statuses.RemoveAll(s => s.Id == id);
|
||||
await DB.Default.SaveAsync(filter, token);
|
||||
return Json(new { });
|
||||
}
|
||||
|
||||
// -- v1: each keyword is a filter of its own, with its parent's context, action and expiry
|
||||
|
||||
static FilterV1Entity V1(PersonaFilter filter, FilterKeyword keyword) => new()
|
||||
{
|
||||
Id = keyword.Id,
|
||||
Phrase = keyword.Keyword,
|
||||
Context = filter.Context,
|
||||
WholeWord = keyword.WholeWord,
|
||||
ExpiresAt = filter.ExpiresAt.HasValue ? MastodonJson.Time(filter.ExpiresAt.Value) : default,
|
||||
Irreversible = filter.Action == FilterActions.Hide
|
||||
};
|
||||
|
||||
[HttpGet("/api/v1/filters"), Scope("read:filters")]
|
||||
public async Task<IActionResult> AllV1(CancellationToken token) =>
|
||||
Json((await DB.Default.Find<PersonaFilter>().Match(f => f.AvatarId == MyId).Sort(f => f.ID, Order.Ascending).ExecuteAsync(token))
|
||||
.SelectMany(f => f.Keywords.Select(k => V1(f, k))).ToList());
|
||||
|
||||
[HttpGet("/api/v1/filters/{id}"), Scope("read:filters")]
|
||||
public async Task<IActionResult> OneV1(string id, CancellationToken token) =>
|
||||
await WithKeyword(id, token) is { } filter ? Json(V1(filter, filter.Keywords.First(k => k.Id == id))) : NotFoundError();
|
||||
|
||||
// phrase, context[], irreversible, whole_word, expires_in
|
||||
string ApplyV1(PersonaFilter filter, FilterKeyword keyword, bool creating)
|
||||
{
|
||||
var phrase = Params.Get("phrase")?.Trim();
|
||||
if (phrase != default || creating)
|
||||
{
|
||||
if (string.IsNullOrEmpty(phrase))
|
||||
return "Phrase can't be blank";
|
||||
if (phrase.Length > MaxKeywordLength)
|
||||
return "Phrase is too long";
|
||||
keyword.Keyword = phrase;
|
||||
if (creating)
|
||||
filter.Title = phrase;
|
||||
}
|
||||
if (Params.Has("context") || creating)
|
||||
{
|
||||
var context = Params.List("context").Distinct().ToList();
|
||||
if (context.Count == 0 || context.Any(c => !FilterContexts.IsValid(c)))
|
||||
return "Context can't be blank";
|
||||
filter.Context = context;
|
||||
}
|
||||
if (Params.Bool("whole_word") is { } wholeWord)
|
||||
keyword.WholeWord = wholeWord;
|
||||
if (Params.Bool("irreversible") is { } irreversible)
|
||||
filter.Action = irreversible ? FilterActions.Hide : FilterActions.Warn;
|
||||
if (Params.Has("expires_in"))
|
||||
filter.ExpiresAt = Params.Int("expires_in") is > 0 and var seconds ? DateTime.UtcNow.AddSeconds(seconds) : default;
|
||||
return default;
|
||||
}
|
||||
|
||||
[HttpPost("/api/v1/filters"), Scope("write:filters")]
|
||||
public async Task<IActionResult> CreateV1(CancellationToken token)
|
||||
{
|
||||
if (await DB.Default.CountAsync<PersonaFilter>(f => f.AvatarId == MyId, token) >= MaxFilters)
|
||||
return Invalid("Too many filters");
|
||||
var keyword = new FilterKeyword { Id = NewId(), WholeWord = true };
|
||||
var filter = new PersonaFilter { AvatarId = MyId, Keywords = new List<FilterKeyword> { keyword } };
|
||||
if (ApplyV1(filter, keyword, creating: true) is { } problem)
|
||||
return Invalid(problem);
|
||||
await DB.Default.SaveAsync(filter, token);
|
||||
return Json(V1(filter, keyword));
|
||||
}
|
||||
|
||||
[HttpPut("/api/v1/filters/{id}"), Scope("write:filters")]
|
||||
public async Task<IActionResult> UpdateV1(string id, CancellationToken token)
|
||||
{
|
||||
if (await WithKeyword(id, token) is not { } filter)
|
||||
return NotFoundError();
|
||||
var keyword = filter.Keywords.First(k => k.Id == id);
|
||||
if (ApplyV1(filter, keyword, creating: false) is { } problem)
|
||||
return Invalid(problem);
|
||||
await DB.Default.SaveAsync(filter, token);
|
||||
return Json(V1(filter, keyword));
|
||||
}
|
||||
|
||||
// the keyword goes, and its filter with it when nothing else is left in it
|
||||
[HttpDelete("/api/v1/filters/{id}"), Scope("write:filters")]
|
||||
public async Task<IActionResult> DeleteV1(string id, CancellationToken token)
|
||||
{
|
||||
if (await WithKeyword(id, token) is not { } filter)
|
||||
return NotFoundError();
|
||||
filter.Keywords.RemoveAll(k => k.Id == id);
|
||||
if (filter.Keywords.Count == 0 && filter.Statuses.Count == 0)
|
||||
await DB.Default.DeleteAsync<PersonaFilter>(filter.ID);
|
||||
else
|
||||
await DB.Default.SaveAsync(filter, token);
|
||||
return Json(new { });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -84,12 +84,6 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
||||
[HttpGet("/api/v1/custom_emojis"), Microsoft.AspNetCore.Authorization.AllowAnonymous]
|
||||
public IActionResult CustomEmojis() => Json(Array.Empty<object>());
|
||||
|
||||
[HttpGet("/api/v1/filters"), Scope("read:filters")]
|
||||
public IActionResult FiltersV1() => Json(Array.Empty<object>());
|
||||
|
||||
[HttpGet("/api/v2/filters"), Scope("read:filters")]
|
||||
public IActionResult FiltersV2() => Json(Array.Empty<object>());
|
||||
|
||||
[HttpGet("/api/v1/announcements"), Microsoft.AspNetCore.Authorization.AllowAnonymous]
|
||||
public IActionResult Announcements() => Json(Array.Empty<object>());
|
||||
|
||||
|
||||
Reference in new issue
Block a user