[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
|
@ -214,6 +214,9 @@ public class AccountController(
|
||||||
p.Notifiee == followee &&
|
p.Notifiee == followee &&
|
||||||
p.Notifier == user))
|
p.Notifier == user))
|
||||||
.ExecuteDeleteAsync();
|
.ExecuteDeleteAsync();
|
||||||
|
|
||||||
|
// Clean up user list memberships
|
||||||
|
await db.UserListMembers.Where(p => p.UserList.User == user && p.User == followee).ExecuteDeleteAsync();
|
||||||
|
|
||||||
var res = new RelationshipEntity
|
var res = new RelationshipEntity
|
||||||
{
|
{
|
||||||
|
|
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.Cors;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.AspNetCore.RateLimiting;
|
using Microsoft.AspNetCore.RateLimiting;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Caching.Distributed;
|
using Microsoft.Extensions.Caching.Distributed;
|
||||||
|
|
||||||
namespace Iceshrimp.Backend.Controllers.Mastodon;
|
namespace Iceshrimp.Backend.Controllers.Mastodon;
|
||||||
|
@ -69,4 +70,26 @@ public class TimelineController(DatabaseContext db, NoteRenderer noteRenderer, I
|
||||||
|
|
||||||
return Ok(res);
|
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("UserListId")]
|
||||||
[Index("UserId", "UserListId", IsUnique = true)]
|
[Index("UserId", "UserListId", IsUnique = true)]
|
||||||
[Index("UserId")]
|
[Index("UserId")]
|
||||||
public class UserListMember
|
public class UserListMember : IEntity
|
||||||
{
|
{
|
||||||
[Key]
|
[Key]
|
||||||
[Column("id")]
|
[Column("id")]
|
||||||
|
|
|
@ -18,11 +18,11 @@ public static class ModelBinderProviderExtensions
|
||||||
CollectionModelBinderProvider collectionProvider)
|
CollectionModelBinderProvider collectionProvider)
|
||||||
throw new Exception("Failed to set up query collection model binding provider");
|
throw new Exception("Failed to set up query collection model binding provider");
|
||||||
|
|
||||||
var hybridProvider = new HybridModelBinderProvider(bodyProvider, complexProvider);
|
var hybridProvider = new HybridModelBinderProvider(bodyProvider, complexProvider);
|
||||||
var queryProvider = new QueryCollectionModelBinderProvider(collectionProvider);
|
var customCollectionProvider = new CustomCollectionModelBinderProvider(collectionProvider);
|
||||||
|
|
||||||
providers.Insert(0, hybridProvider);
|
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)
|
public IModelBinder? GetBinder(ModelBinderProviderContext context)
|
||||||
{
|
{
|
||||||
if (context.BindingInfo.BindingSource == null) return null;
|
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;
|
if (!context.Metadata.IsCollectionType) return null;
|
||||||
|
|
||||||
var binder = provider.GetBinder(context);
|
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)
|
public async Task BindModelAsync(ModelBindingContext bindingContext)
|
||||||
{
|
{
|
||||||
|
@ -110,8 +112,9 @@ public class QueryCollectionModelBinder(IModelBinder? binder) : IModelBinder
|
||||||
}
|
}
|
||||||
|
|
||||||
[AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Property)]
|
[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;
|
public BindingSource BindingSource => HybridBindingSource.Hybrid;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -418,5 +418,9 @@ public class ActivityHandlerService(
|
||||||
.Where(p => p.Notifiee == resolvedFollower &&
|
.Where(p => p.Notifiee == resolvedFollower &&
|
||||||
p.Notifier == actor)
|
p.Notifier == actor)
|
||||||
.ExecuteDeleteAsync();
|
.ExecuteDeleteAsync();
|
||||||
|
|
||||||
|
await db.UserListMembers
|
||||||
|
.Where(p => p.UserList.User == resolvedFollower && p.User == actor)
|
||||||
|
.ExecuteDeleteAsync();
|
||||||
}
|
}
|
||||||
}
|
}
|
Loading…
Add table
Reference in a new issue