diff --git a/Iceshrimp.Backend/Controllers/Mastodon/AccountController.cs b/Iceshrimp.Backend/Controllers/Mastodon/AccountController.cs index 5b42e1f3..59b58d39 100644 --- a/Iceshrimp.Backend/Controllers/Mastodon/AccountController.cs +++ b/Iceshrimp.Backend/Controllers/Mastodon/AccountController.cs @@ -214,6 +214,9 @@ public class AccountController( p.Notifiee == followee && p.Notifier == user)) .ExecuteDeleteAsync(); + + // Clean up user list memberships + await db.UserListMembers.Where(p => p.UserList.User == user && p.User == followee).ExecuteDeleteAsync(); var res = new RelationshipEntity { diff --git a/Iceshrimp.Backend/Controllers/Mastodon/ListController.cs b/Iceshrimp.Backend/Controllers/Mastodon/ListController.cs new file mode 100644 index 00000000..f8637542 --- /dev/null +++ b/Iceshrimp.Backend/Controllers/Mastodon/ListController.cs @@ -0,0 +1,214 @@ +using System.Diagnostics.CodeAnalysis; +using System.Net.Mime; +using Iceshrimp.Backend.Controllers.Attributes; +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.Helpers; +using Iceshrimp.Backend.Core.Middleware; +using Microsoft.AspNetCore.Cors; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Caching.Distributed; + +namespace Iceshrimp.Backend.Controllers.Mastodon; + +[MastodonApiController] +[Route("/api/v1/lists")] +[Authenticate] +[EnableRateLimiting("sliding")] +[EnableCors("mastodon")] +[Produces(MediaTypeNames.Application.Json)] +[ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(MastodonErrorResponse))] +[ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(MastodonErrorResponse))] +public class ListController(DatabaseContext db, UserRenderer userRenderer) : ControllerBase +{ + [HttpGet] + [Authorize("read:lists")] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(IEnumerable))] + public async Task GetLists() + { + var user = HttpContext.GetUserOrFail(); + + var res = await db.UserLists + .Where(p => p.User == user) + .Select(p => new ListEntity { Id = p.Id, Title = p.Name, Exclusive = p.HideFromHomeTl }) + .ToListAsync(); + + return Ok(res); + } + + [HttpGet("{id}")] + [Authorize("read:lists")] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(ListEntity))] + [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))] + public async Task GetList(string id) + { + var user = HttpContext.GetUserOrFail(); + + var res = await db.UserLists + .Where(p => p.User == user && p.Id == id) + .Select(p => new ListEntity { Id = p.Id, Title = p.Name, Exclusive = p.HideFromHomeTl }) + .FirstOrDefaultAsync() ?? + throw GracefulException.RecordNotFound(); + + return Ok(res); + } + + [HttpPost] + [Authorize("write:lists")] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(ListEntity))] + [ProducesResponseType(StatusCodes.Status422UnprocessableEntity, Type = typeof(MastodonErrorResponse))] + public async Task CreateList([FromHybrid] ListSchemas.ListCreationRequest request) + { + if (string.IsNullOrWhiteSpace(request.Title)) + throw GracefulException.UnprocessableEntity("Validation failed: Title can't be blank"); + + var user = HttpContext.GetUserOrFail(); + var list = new UserList + { + Id = IdHelpers.GenerateSlowflakeId(), + CreatedAt = DateTime.UtcNow, + User = user, + Name = request.Title, + HideFromHomeTl = request.Exclusive + }; + + await db.AddAsync(list); + await db.SaveChangesAsync(); + + var res = new ListEntity { Id = list.Id, Title = list.Name, Exclusive = list.HideFromHomeTl }; + return Ok(res); + } + + [HttpPut("{id}")] + [Authorize("write:lists")] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(ListEntity))] + [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))] + [ProducesResponseType(StatusCodes.Status422UnprocessableEntity, Type = typeof(MastodonErrorResponse))] + public async Task UpdateList(string id, [FromHybrid] ListSchemas.ListCreationRequest request) + { + var user = HttpContext.GetUserOrFail(); + var list = await db.UserLists + .Where(p => p.User == user && p.Id == id) + .FirstOrDefaultAsync() ?? + throw GracefulException.RecordNotFound(); + + if (string.IsNullOrWhiteSpace(request.Title)) + throw GracefulException.UnprocessableEntity("Validation failed: Title can't be blank"); + + + list.Name = request.Title; + list.HideFromHomeTl = request.Exclusive; + + db.Update(list); + await db.SaveChangesAsync(); + + var res = new ListEntity { Id = list.Id, Title = list.Name, Exclusive = list.HideFromHomeTl }; + return Ok(res); + } + + [HttpDelete("{id}")] + [Authorize("write:lists")] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(object))] + [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))] + public async Task DeleteList(string id) + { + var user = HttpContext.GetUserOrFail(); + var list = await db.UserLists + .Where(p => p.User == user && p.Id == id) + .FirstOrDefaultAsync() ?? + throw GracefulException.RecordNotFound(); + + db.Remove(list); + await db.SaveChangesAsync(); + return Ok(new object()); + } + + [LinkPagination(40, 80)] + [HttpGet("{id}/accounts")] + [Authorize("read:lists")] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(List))] + [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))] + public async Task GetListMembers(string id, MastodonPaginationQuery pq) + { + var user = HttpContext.GetUserOrFail(); + var list = await db.UserLists + .Where(p => p.User == user && p.Id == id) + .FirstOrDefaultAsync() ?? + throw GracefulException.RecordNotFound(); + + var res = await db.UserListMembers + .Where(p => p.UserList == list) + .Paginate(pq, ControllerContext) + .Include(p => p.User.UserProfile) + .Select(p => p.User) + .RenderAllForMastodonAsync(userRenderer); + + return Ok(res); + } + + [HttpPost("{id}/accounts")] + [Authorize("write:lists")] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(object))] + [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))] + [ProducesResponseType(StatusCodes.Status422UnprocessableEntity, Type = typeof(MastodonErrorResponse))] + [SuppressMessage("ReSharper", "EntityFramework.UnsupportedServerSideFunctionCall", Justification = "Projectables")] + public async Task AddListMember(string id, [FromHybrid] ListSchemas.ListUpdateMembersRequest request) + { + var user = HttpContext.GetUserOrFail(); + var list = await db.UserLists + .Where(p => p.User == user && p.Id == id) + .FirstOrDefaultAsync() ?? + throw GracefulException.RecordNotFound(); + + var subjects = await db.Users.Where(p => request.AccountIds.Contains(p.Id) && p.IsFollowedBy(user)) + .Select(p => p.Id) + .ToListAsync(); + + if (subjects.Count == 0 || subjects.Count != request.AccountIds.Count) + throw GracefulException.RecordNotFound(); + + if (await db.UserListMembers.AnyAsync(p => subjects.Contains(p.UserId) && p.UserList == list)) + throw GracefulException.UnprocessableEntity("Validation failed: Account has already been taken"); + + var memberships = subjects.Select(subject => new UserListMember + { + Id = IdHelpers.GenerateSlowflakeId(), + CreatedAt = DateTime.UtcNow, + UserList = list, + UserId = subject + }); + + await db.AddRangeAsync(memberships); + await db.SaveChangesAsync(); + + return Ok(new object()); + } + + [HttpDelete("{id}/accounts")] + [Authorize("write:lists")] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(object))] + [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))] + public async Task RemoveListMember( + string id, [FromHybrid] ListSchemas.ListUpdateMembersRequest request + ) + { + var user = HttpContext.GetUserOrFail(); + var list = await db.UserLists + .Where(p => p.User == user && p.Id == id) + .FirstOrDefaultAsync() ?? + throw GracefulException.RecordNotFound(); + + await db.UserListMembers + .Where(p => p.UserList == list && request.AccountIds.Contains(p.UserId)) + .ExecuteDeleteAsync(); + + return Ok(new object()); + } +} \ No newline at end of file diff --git a/Iceshrimp.Backend/Controllers/Mastodon/Schemas/Entities/ListEntity.cs b/Iceshrimp.Backend/Controllers/Mastodon/Schemas/Entities/ListEntity.cs new file mode 100644 index 00000000..8a1a6ee7 --- /dev/null +++ b/Iceshrimp.Backend/Controllers/Mastodon/Schemas/Entities/ListEntity.cs @@ -0,0 +1,12 @@ +using J = System.Text.Json.Serialization.JsonPropertyNameAttribute; + +namespace Iceshrimp.Backend.Controllers.Mastodon.Schemas.Entities; + +public class ListEntity +{ + [J("id")] public required string Id { get; set; } + [J("title")] public required string Title { get; set; } + [J("exclusive")] public required bool Exclusive { get; set; } + + [J("replies_policy")] public string RepliesPolicy => "followed"; //FIXME +} \ No newline at end of file diff --git a/Iceshrimp.Backend/Controllers/Mastodon/Schemas/ListSchemas.cs b/Iceshrimp.Backend/Controllers/Mastodon/Schemas/ListSchemas.cs new file mode 100644 index 00000000..8982362d --- /dev/null +++ b/Iceshrimp.Backend/Controllers/Mastodon/Schemas/ListSchemas.cs @@ -0,0 +1,18 @@ +using B = Microsoft.AspNetCore.Mvc.BindPropertyAttribute; + +namespace Iceshrimp.Backend.Controllers.Mastodon.Schemas; + +public abstract class ListSchemas +{ + public class ListCreationRequest + { + [B(Name = "title")] public required string Title { get; set; } + [B(Name = "replies_policy")] public string RepliesPolicy { get; set; } = "list"; + [B(Name = "exclusive")] public bool Exclusive { get; set; } = false; + } + + public class ListUpdateMembersRequest + { + [B(Name = "account_ids")] public required List AccountIds { get; set; } + } +} \ No newline at end of file diff --git a/Iceshrimp.Backend/Controllers/Mastodon/TimelineController.cs b/Iceshrimp.Backend/Controllers/Mastodon/TimelineController.cs index 4bb4dd37..d90bf739 100644 --- a/Iceshrimp.Backend/Controllers/Mastodon/TimelineController.cs +++ b/Iceshrimp.Backend/Controllers/Mastodon/TimelineController.cs @@ -11,6 +11,7 @@ using Iceshrimp.Backend.Core.Middleware; using Microsoft.AspNetCore.Cors; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RateLimiting; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Caching.Distributed; namespace Iceshrimp.Backend.Controllers.Mastodon; @@ -69,4 +70,26 @@ public class TimelineController(DatabaseContext db, NoteRenderer noteRenderer, I return Ok(res); } + + [Authorize("read:lists")] + [HttpGet("list/{id}")] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(IEnumerable))] + public async Task GetListTimeline(string id, MastodonPaginationQuery query) + { + var user = HttpContext.GetUserOrFail(); + if (!await db.UserLists.AnyAsync(p => p.Id == id && p.User == user)) + throw GracefulException.RecordNotFound(); + + var res = await db.Notes + .IncludeCommonProperties() + .Where(p => db.UserListMembers.Any(l => l.UserListId == id && l.UserId == p.UserId)) + .EnsureVisibleFor(user) + .FilterBlocked(user) + .FilterMuted(user) + .Paginate(query, ControllerContext) + .PrecomputeVisibilities(user) + .RenderAllForMastodonAsync(noteRenderer, user); + + return Ok(res); + } } \ No newline at end of file diff --git a/Iceshrimp.Backend/Core/Database/Tables/UserListMember.cs b/Iceshrimp.Backend/Core/Database/Tables/UserListMember.cs index 472826f8..f8afc7d8 100644 --- a/Iceshrimp.Backend/Core/Database/Tables/UserListMember.cs +++ b/Iceshrimp.Backend/Core/Database/Tables/UserListMember.cs @@ -8,7 +8,7 @@ namespace Iceshrimp.Backend.Core.Database.Tables; [Index("UserListId")] [Index("UserId", "UserListId", IsUnique = true)] [Index("UserId")] -public class UserListMember +public class UserListMember : IEntity { [Key] [Column("id")] diff --git a/Iceshrimp.Backend/Core/Extensions/ModelBinderProviderExtensions.cs b/Iceshrimp.Backend/Core/Extensions/ModelBinderProviderExtensions.cs index 04807bdc..dc70271b 100644 --- a/Iceshrimp.Backend/Core/Extensions/ModelBinderProviderExtensions.cs +++ b/Iceshrimp.Backend/Core/Extensions/ModelBinderProviderExtensions.cs @@ -18,11 +18,11 @@ public static class ModelBinderProviderExtensions CollectionModelBinderProvider collectionProvider) throw new Exception("Failed to set up query collection model binding provider"); - var hybridProvider = new HybridModelBinderProvider(bodyProvider, complexProvider); - var queryProvider = new QueryCollectionModelBinderProvider(collectionProvider); + var hybridProvider = new HybridModelBinderProvider(bodyProvider, complexProvider); + var customCollectionProvider = new CustomCollectionModelBinderProvider(collectionProvider); providers.Insert(0, hybridProvider); - providers.Insert(1, queryProvider); + providers.Insert(1, customCollectionProvider); } } @@ -46,16 +46,18 @@ public class HybridModelBinderProvider( } } -public class QueryCollectionModelBinderProvider(IModelBinderProvider provider) : IModelBinderProvider +public class CustomCollectionModelBinderProvider(IModelBinderProvider provider) : IModelBinderProvider { public IModelBinder? GetBinder(ModelBinderProviderContext context) { if (context.BindingInfo.BindingSource == null) return null; - if (!context.BindingInfo.BindingSource.CanAcceptDataFrom(BindingSource.Query)) return null; + if (!context.BindingInfo.BindingSource.CanAcceptDataFrom(BindingSource.Query) && + !context.BindingInfo.BindingSource.CanAcceptDataFrom(BindingSource.Form) && + !context.BindingInfo.BindingSource.CanAcceptDataFrom(BindingSource.ModelBinding)) return null; if (!context.Metadata.IsCollectionType) return null; var binder = provider.GetBinder(context); - return new QueryCollectionModelBinder(binder); + return new CustomCollectionModelBinder(binder); } } @@ -83,7 +85,7 @@ public class HybridModelBinder(IModelBinder? bodyBinder, IModelBinder? complexBi } } -public class QueryCollectionModelBinder(IModelBinder? binder) : IModelBinder +public class CustomCollectionModelBinder(IModelBinder? binder) : IModelBinder { public async Task BindModelAsync(ModelBindingContext bindingContext) { @@ -110,8 +112,9 @@ public class QueryCollectionModelBinder(IModelBinder? binder) : IModelBinder } [AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Property)] -public class FromHybridAttribute : Attribute, IBindingSourceMetadata +public class FromHybridAttribute(string? name = null) : Attribute, IBindingSourceMetadata, IModelNameProvider { + public string? Name => name; public BindingSource BindingSource => HybridBindingSource.Hybrid; } diff --git a/Iceshrimp.Backend/Core/Federation/ActivityPub/ActivityHandlerService.cs b/Iceshrimp.Backend/Core/Federation/ActivityPub/ActivityHandlerService.cs index 4f168816..33a967bd 100644 --- a/Iceshrimp.Backend/Core/Federation/ActivityPub/ActivityHandlerService.cs +++ b/Iceshrimp.Backend/Core/Federation/ActivityPub/ActivityHandlerService.cs @@ -418,5 +418,9 @@ public class ActivityHandlerService( .Where(p => p.Notifiee == resolvedFollower && p.Notifier == actor) .ExecuteDeleteAsync(); + + await db.UserListMembers + .Where(p => p.UserList.User == resolvedFollower && p.User == actor) + .ExecuteDeleteAsync(); } } \ No newline at end of file