[backend/masto-client] Add user list support
This commit is contained in:
parent
703e58b3fe
commit
3554503058
8 changed files with 286 additions and 9 deletions
|
@ -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,
|
||||
|
|
214
Iceshrimp.Backend/Controllers/Mastodon/ListController.cs
Normal file
214
Iceshrimp.Backend/Controllers/Mastodon/ListController.cs
Normal 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());
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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; }
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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")]
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
Loading…
Add table
Reference in a new issue