using Microsoft.AspNetCore.Mvc; using MongoDB.Entities; using PrivaPub.Api.Mastodon.Infrastructure; using PrivaPub.Api.Mastodon.Mappers; using PrivaPub.Domain.Privacy; using PrivaPub.Models.Social; using PrivaPub.StaticServices; namespace PrivaPub.Api.Mastodon.Controllers { // Mastodon's lists (owner decision 2026-10-04, back from the cut list): a persona groups accounts it follows, reads them // as a timeline, and may keep an exclusive list's members out of its home. Nothing here leaves the server. public class ListsController : MastodonController { const int MaxLists = 50; const int MaxMembers = 500; readonly DbEntities _dbEntities; readonly MastodonMapper _mapper; public ListsController(DbEntities dbEntities, MastodonMapper mapper) { _dbEntities = dbEntities; _mapper = mapper; } static object View(PersonaList list) => new { id = list.ID, title = list.Title, replies_policy = list.RepliesPolicy, exclusive = list.Exclusive }; Task Mine(string id, CancellationToken token) => DB.Default.Find().Match(l => l.ID == id && l.AvatarId == MyId).ExecuteFirstAsync(token); // a list holds only accounts the persona still follows: whatever ended the follow (an unfollow, a Reject, a Block, the // account's deletion), its membership goes with it, pruned here on the next read async Task> Members(PersonaList list, CancellationToken token) { var members = (await DB.Default.Find().Match(m => m.ListId == list.ID).ExecuteAsync(token)).Select(m => m.AccountId).ToList(); if (members.Count == 0) return members; var followed = (await _dbEntities.Followings.Match(f => f.AvatarId == MyId && f.State == FollowState.Accepted && members.Contains(f.TargetAccountId)) .ExecuteAsync(token)).Select(f => f.TargetAccountId).ToHashSet(); var gone = members.Where(m => !followed.Contains(m)).ToList(); if (gone.Count > 0) await DB.Default.DeleteAsync(m => m.ListId == list.ID && gone.Contains(m.AccountId)); return members.Where(followed.Contains).ToList(); } [HttpGet("/api/v1/lists"), Scope("read:lists")] public async Task All(CancellationToken token) => Json((await DB.Default.Find().Match(l => l.AvatarId == MyId).Sort(l => l.Title, MongoDB.Entities.Order.Ascending) .ExecuteAsync(token)).Select(View).ToList()); [HttpGet("/api/v1/lists/{id}"), Scope("read:lists")] public async Task One(string id, CancellationToken token) => await Mine(id, token) is { } list ? Json(View(list)) : NotFoundError(); [HttpPost("/api/v1/lists"), Scope("write:lists")] public async Task Create(CancellationToken token) { var title = Params.Get("title")?.Trim(); var policy = Params.Get("replies_policy") ?? ListRepliesPolicy.List; if (string.IsNullOrEmpty(title) || title.Length > 200) return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Title can't be blank"); if (!ListRepliesPolicy.IsValid(policy)) return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Replies policy is not included in the list"); if (await DB.Default.CountAsync(l => l.AvatarId == MyId, token) >= MaxLists) return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Too many lists"); var list = new PersonaList { AvatarId = MyId, Title = title, RepliesPolicy = policy, Exclusive = Params.Bool("exclusive") == true }; await DB.Default.SaveAsync(list, token); return Json(View(list)); } [HttpPut("/api/v1/lists/{id}"), Scope("write:lists")] public async Task Update(string id, CancellationToken token) { if (await Mine(id, token) is not { } list) return NotFoundError(); var title = Params.Get("title")?.Trim(); var policy = Params.Get("replies_policy"); if (title != default) { if (title.Length == 0 || title.Length > 200) return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Title can't be blank"); list.Title = title; } if (policy != default) { if (!ListRepliesPolicy.IsValid(policy)) return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Replies policy is not included in the list"); list.RepliesPolicy = policy; } if (Params.Bool("exclusive") is { } exclusive) list.Exclusive = exclusive; await DB.Default.SaveAsync(list, token); return Json(View(list)); } [HttpDelete("/api/v1/lists/{id}"), Scope("write:lists")] public async Task Delete(string id, CancellationToken token) { if (await Mine(id, token) is not { } list) return NotFoundError(); await DB.Default.DeleteAsync(m => m.ListId == list.ID); await DB.Default.DeleteAsync(list.ID); return Json(new { }); } [HttpGet("/api/v1/lists/{id}/accounts"), Scope("read:lists")] public async Task Accounts(string id, CancellationToken token) { if (await Mine(id, token) is not { } list) return NotFoundError(); await Members(list, token); var members = await Page.From(Params, Limit(40, 80)).Fetch(DB.Default.Find().Match(m => m.ListId == list.ID), m => m.ID, token); var accounts = await _mapper.Accounts(members.Select(m => m.AccountId), token); Link($"/api/v1/lists/{id}/accounts", members.LastOrDefault()?.ID, members.FirstOrDefault()?.ID); return Json(members.Where(m => accounts.ContainsKey(m.AccountId)).Select(m => accounts[m.AccountId]).ToList()); } // only accounts the persona follows may join its list, as on Mastodon [HttpPost("/api/v1/lists/{id}/accounts"), Scope("write:lists")] public async Task Add(string id, CancellationToken token) { if (await Mine(id, token) is not { } list) return NotFoundError(); var ids = Params.List("account_ids").Distinct().ToList(); if (ids.Count == 0) return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Account ids can't be blank"); var followed = (await _dbEntities.Followings.Match(f => f.AvatarId == MyId && f.State == FollowState.Accepted && ids.Contains(f.TargetAccountId)) .ExecuteAsync(token)).Select(f => f.TargetAccountId).ToHashSet(); if (ids.Any(a => !followed.Contains(a))) return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: You must follow this account to add it to a list"); var present = (await DB.Default.Find().Match(m => m.ListId == list.ID && ids.Contains(m.AccountId)).ExecuteAsync(token)) .Select(m => m.AccountId).ToHashSet(); if (await DB.Default.CountAsync(m => m.ListId == list.ID, token) + ids.Count(a => !present.Contains(a)) > MaxMembers) return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Too many accounts in the list"); foreach (var account in ids.Where(a => !present.Contains(a))) await DB.Default.SaveAsync(new PersonaListMember { ListId = list.ID, AvatarId = MyId, AccountId = account }, token); return Json(new { }); } [HttpDelete("/api/v1/lists/{id}/accounts"), Scope("write:lists")] public async Task Remove(string id, CancellationToken token) { if (await Mine(id, token) is not { } list) return NotFoundError(); var ids = Params.List("account_ids"); await DB.Default.DeleteAsync(m => m.ListId == list.ID && ids.Contains(m.AccountId)); return Json(new { }); } [HttpGet("/api/v1/accounts/{id}/lists"), Scope("read:lists")] public async Task Containing(string id, CancellationToken token) { var listIds = (await DB.Default.Find().Match(m => m.AvatarId == MyId && m.AccountId == id).ExecuteAsync(token)) .Select(m => m.ListId).ToList(); return Json((await DB.Default.Find().Match(l => l.AvatarId == MyId && listIds.Contains(l.ID)).ExecuteAsync(token)) .Select(View).ToList()); } // the list's timeline: the persona's home entries by its members. Replies are kept as Mastodon keeps them: a member's // replies to itself and to the persona always, to others as the policy says (anyone followed, the list's members, no one) [HttpGet("/api/v1/timelines/list/{id}"), Scope("read:lists")] public async Task Timeline(string id, CancellationToken token) { if (await Mine(id, token) is not { } list) return NotFoundError(); var members = await Members(list, token); var entries = await Page.From(Params, Limit()).Fetch( _dbEntities.TimelineEntries.Match(e => e.AvatarId == MyId && members.Contains(e.AuthorAccountId)), e => e.PostId, token); var ids = entries.Select(e => e.PostId).ToList(); var posts = (await _dbEntities.Posts.Match(p => ids.Contains(p.ID)).Match(VisibilityPolicy.IsShown).ExecuteAsync(token)).ToDictionary(p => p.ID); var answered = list.RepliesPolicy switch { ListRepliesPolicy.Followed => (await _dbEntities.Followings.Match(f => f.AvatarId == MyId && f.State == FollowState.Accepted).ExecuteAsync(token)) .Select(f => f.TargetAccountId).Append(MyId).ToHashSet(), ListRepliesPolicy.List => members.Append(MyId).ToHashSet(), _ => new HashSet { MyId } }; var shown = ids.Where(posts.ContainsKey).Select(i => posts[i]) .Where(p => p.ReblogOfPostId != default || string.IsNullOrEmpty(p.AnsweringToPostId) && string.IsNullOrEmpty(p.InReplyToURI) || p.InReplyToAccountId != default && (p.InReplyToAccountId == p.AuthorAccountId || answered.Contains(p.InReplyToAccountId))) .ToList(); Link($"/api/v1/timelines/list/{id}", entries.LastOrDefault()?.PostId, entries.FirstOrDefault()?.PostId); return Json(await _mapper.Statuses(shown, MyId, token)); } } }