[backend/masto-client] Add user list support

This commit is contained in:
Laura Hausmann 2024-02-23 00:13:54 +01:00
parent 703e58b3fe
commit 3554503058
No known key found for this signature in database
GPG key ID: D044E84C5BE01605
8 changed files with 286 additions and 9 deletions

View file

@ -215,6 +215,9 @@ public class AccountController(
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
{
Id = followee.Id,

View file

@ -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<ListEntity>))]
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<AccountEntity>))]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))]
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> 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());
}
}

View file

@ -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
}

View file

@ -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<string> AccountIds { get; set; }
}
}

View file

@ -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<StatusEntity>))]
public async Task<IActionResult> 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);
}
}

View file

@ -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")]

View file

@ -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;
}

View file

@ -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();
}
}