[backend/masto-client] Add follow/unfollow/relationship endpoints
This commit is contained in:
parent
e31a0719f4
commit
8c9e6ef56c
12 changed files with 351 additions and 21 deletions
|
@ -1,13 +1,16 @@
|
||||||
using Iceshrimp.Backend.Controllers.Mastodon.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;
|
||||||
using Iceshrimp.Backend.Controllers.Mastodon.Schemas.Entities;
|
using Iceshrimp.Backend.Controllers.Mastodon.Schemas.Entities;
|
||||||
using Iceshrimp.Backend.Core.Database;
|
using Iceshrimp.Backend.Core.Database;
|
||||||
|
using Iceshrimp.Backend.Core.Database.Tables;
|
||||||
using Iceshrimp.Backend.Core.Extensions;
|
using Iceshrimp.Backend.Core.Extensions;
|
||||||
|
using Iceshrimp.Backend.Core.Federation.ActivityPub;
|
||||||
|
using Iceshrimp.Backend.Core.Helpers;
|
||||||
using Iceshrimp.Backend.Core.Middleware;
|
using Iceshrimp.Backend.Core.Middleware;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.AspNetCore.RateLimiting;
|
using Microsoft.AspNetCore.RateLimiting;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using MastodonUserRenderer = Iceshrimp.Backend.Controllers.Mastodon.Renderers.UserRenderer;
|
||||||
|
|
||||||
namespace Iceshrimp.Backend.Controllers.Mastodon;
|
namespace Iceshrimp.Backend.Controllers.Mastodon;
|
||||||
|
|
||||||
|
@ -16,10 +19,15 @@ namespace Iceshrimp.Backend.Controllers.Mastodon;
|
||||||
[Authenticate]
|
[Authenticate]
|
||||||
[EnableRateLimiting("sliding")]
|
[EnableRateLimiting("sliding")]
|
||||||
[Produces("application/json")]
|
[Produces("application/json")]
|
||||||
public class MastodonAccountController(DatabaseContext db, UserRenderer userRenderer) : Controller {
|
public class MastodonAccountController(
|
||||||
[Authorize("read:accounts")]
|
DatabaseContext db,
|
||||||
|
MastodonUserRenderer userRenderer,
|
||||||
|
ActivityRenderer activityRenderer,
|
||||||
|
UserRenderer apUserRenderer,
|
||||||
|
ActivityDeliverService deliverSvc
|
||||||
|
) : Controller {
|
||||||
[HttpGet("verify_credentials")]
|
[HttpGet("verify_credentials")]
|
||||||
[Produces("application/json")]
|
[Authorize("read:accounts")]
|
||||||
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(Account))]
|
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(Account))]
|
||||||
[ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(MastodonErrorResponse))]
|
[ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(MastodonErrorResponse))]
|
||||||
[ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(MastodonErrorResponse))]
|
[ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(MastodonErrorResponse))]
|
||||||
|
@ -30,7 +38,6 @@ public class MastodonAccountController(DatabaseContext db, UserRenderer userRend
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("{id}")]
|
[HttpGet("{id}")]
|
||||||
[Produces("application/json")]
|
|
||||||
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(Account))]
|
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(Account))]
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))]
|
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))]
|
||||||
public async Task<IActionResult> GetUser(string id) {
|
public async Task<IActionResult> GetUser(string id) {
|
||||||
|
@ -39,4 +46,177 @@ public class MastodonAccountController(DatabaseContext db, UserRenderer userRend
|
||||||
var res = await userRenderer.RenderAsync(user);
|
var res = await userRenderer.RenderAsync(user);
|
||||||
return Ok(res);
|
return Ok(res);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPost("{id}/follow")]
|
||||||
|
[Authorize("write:follows")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(Relationship))]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(MastodonErrorResponse))]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(MastodonErrorResponse))]
|
||||||
|
//TODO: [FromHybrid] request (bool reblogs, bool notify, bool languages)
|
||||||
|
public async Task<IActionResult> FollowUser(string id) {
|
||||||
|
var user = HttpContext.GetUser() ?? throw new GracefulException("Failed to get user from HttpContext");
|
||||||
|
if (user.Id == id)
|
||||||
|
throw GracefulException.BadRequest("You cannot follow yourself");
|
||||||
|
|
||||||
|
var followee = await db.Users.IncludeCommonProperties()
|
||||||
|
.Where(p => p.Id == id)
|
||||||
|
.PrecomputeRelationshipData(user)
|
||||||
|
.FirstOrDefaultAsync()
|
||||||
|
?? throw GracefulException.RecordNotFound();
|
||||||
|
|
||||||
|
if ((followee.PrecomputedIsBlockedBy ?? true) || (followee.PrecomputedIsBlocking ?? true))
|
||||||
|
throw GracefulException.Forbidden("This action is not allowed");
|
||||||
|
|
||||||
|
if (!(followee.PrecomputedIsFollowedBy ?? false) && !(followee.PrecomputedIsRequestedBy ?? false)) {
|
||||||
|
if (followee.Host != null) {
|
||||||
|
var followerActor = await apUserRenderer.RenderAsync(user);
|
||||||
|
var followeeActor = await apUserRenderer.RenderAsync(followee);
|
||||||
|
var followId = activityRenderer.RenderFollowId(user, followee);
|
||||||
|
var activity = ActivityRenderer.RenderFollow(followerActor, followeeActor, followId);
|
||||||
|
await deliverSvc.DeliverToAsync(activity, user, followee);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (followee.IsLocked || followee.Host != null) {
|
||||||
|
var request = new FollowRequest {
|
||||||
|
Id = IdHelpers.GenerateSlowflakeId(),
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
Followee = followee,
|
||||||
|
Follower = user,
|
||||||
|
FolloweeHost = followee.Host,
|
||||||
|
FollowerHost = user.Host,
|
||||||
|
FolloweeInbox = followee.Inbox,
|
||||||
|
FollowerInbox = user.Inbox,
|
||||||
|
FolloweeSharedInbox = followee.SharedInbox,
|
||||||
|
FollowerSharedInbox = user.SharedInbox
|
||||||
|
};
|
||||||
|
|
||||||
|
await db.AddAsync(request);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
var following = new Following {
|
||||||
|
Id = IdHelpers.GenerateSlowflakeId(),
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
Followee = followee,
|
||||||
|
Follower = user,
|
||||||
|
FolloweeHost = followee.Host,
|
||||||
|
FollowerHost = user.Host,
|
||||||
|
FolloweeInbox = followee.Inbox,
|
||||||
|
FollowerInbox = user.Inbox,
|
||||||
|
FolloweeSharedInbox = followee.SharedInbox,
|
||||||
|
FollowerSharedInbox = user.SharedInbox
|
||||||
|
};
|
||||||
|
|
||||||
|
await db.AddAsync(following);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
if (followee.IsLocked)
|
||||||
|
followee.PrecomputedIsRequestedBy = true;
|
||||||
|
else
|
||||||
|
followee.PrecomputedIsFollowedBy = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
var res = new Relationship {
|
||||||
|
Id = followee.Id,
|
||||||
|
Following = followee.PrecomputedIsFollowedBy ?? false,
|
||||||
|
FollowedBy = followee.PrecomputedIsFollowing ?? false,
|
||||||
|
Blocking = followee.PrecomputedIsBlockedBy ?? false,
|
||||||
|
BlockedBy = followee.PrecomputedIsBlocking ?? false,
|
||||||
|
Requested = followee.PrecomputedIsRequestedBy ?? false,
|
||||||
|
RequestedBy = followee.PrecomputedIsRequested ?? false,
|
||||||
|
Muting = followee.PrecomputedIsMutedBy ?? false,
|
||||||
|
Endorsed = false, //FIXME
|
||||||
|
Note = "", //FIXME
|
||||||
|
Notifying = false, //FIXME
|
||||||
|
DomainBlocking = false, //FIXME
|
||||||
|
MutingNotifications = false, //FIXME
|
||||||
|
ShowingReblogs = true, //FIXME
|
||||||
|
};
|
||||||
|
|
||||||
|
return Ok(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("relationships")]
|
||||||
|
[Authorize("read:follows")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(Relationship[]))]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(MastodonErrorResponse))]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(MastodonErrorResponse))]
|
||||||
|
public async Task<IActionResult> GetRelationships([FromQuery(Name = "id")] List<string> ids) {
|
||||||
|
var user = HttpContext.GetUser() ?? throw new GracefulException("Failed to get user from HttpContext");
|
||||||
|
|
||||||
|
if (ids.Contains(user.Id))
|
||||||
|
throw GracefulException.BadRequest("You cannot request relationship status with yourself");
|
||||||
|
|
||||||
|
var users = await db.Users.IncludeCommonProperties()
|
||||||
|
.Where(p => ids.Contains(p.Id))
|
||||||
|
.PrecomputeRelationshipData(user)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var res = users.Select(u => new Relationship {
|
||||||
|
Id = u.Id,
|
||||||
|
Following = u.PrecomputedIsFollowedBy ?? false,
|
||||||
|
FollowedBy = u.PrecomputedIsFollowing ?? false,
|
||||||
|
Blocking = u.PrecomputedIsBlockedBy ?? false,
|
||||||
|
BlockedBy = u.PrecomputedIsBlocking ?? false,
|
||||||
|
Requested = u.PrecomputedIsRequestedBy ?? false,
|
||||||
|
RequestedBy = u.PrecomputedIsRequested ?? false,
|
||||||
|
Muting = u.PrecomputedIsMutedBy ?? false,
|
||||||
|
Endorsed = false, //FIXME
|
||||||
|
Note = "", //FIXME
|
||||||
|
Notifying = false, //FIXME
|
||||||
|
DomainBlocking = false, //FIXME
|
||||||
|
MutingNotifications = false, //FIXME
|
||||||
|
ShowingReblogs = true, //FIXME
|
||||||
|
});
|
||||||
|
|
||||||
|
return Ok(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{id}/unfollow")]
|
||||||
|
[Authorize("write:follows")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(Relationship))]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(MastodonErrorResponse))]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(MastodonErrorResponse))]
|
||||||
|
public async Task<IActionResult> UnfollowUser(string id) {
|
||||||
|
var user = HttpContext.GetUser() ?? throw new GracefulException("Failed to get user from HttpContext");
|
||||||
|
if (user.Id == id)
|
||||||
|
throw GracefulException.BadRequest("You cannot unfollow yourself");
|
||||||
|
|
||||||
|
var followee = await db.Users.IncludeCommonProperties()
|
||||||
|
.Where(p => p.Id == id)
|
||||||
|
.PrecomputeRelationshipData(user)
|
||||||
|
.FirstOrDefaultAsync()
|
||||||
|
?? throw GracefulException.RecordNotFound();
|
||||||
|
|
||||||
|
// TODO: send federation events for remote users
|
||||||
|
if (!(followee.PrecomputedIsFollowedBy ?? false)) {
|
||||||
|
await db.Followings.Where(p => p.Follower == user && p.Followee == followee).ExecuteDeleteAsync();
|
||||||
|
followee.PrecomputedIsFollowedBy = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (followee.PrecomputedIsRequestedBy ?? false) {
|
||||||
|
await db.FollowRequests.Where(p => p.Follower == user && p.Followee == followee).ExecuteDeleteAsync();
|
||||||
|
followee.PrecomputedIsRequestedBy = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var res = new Relationship {
|
||||||
|
Id = followee.Id,
|
||||||
|
Following = followee.PrecomputedIsFollowedBy ?? false,
|
||||||
|
FollowedBy = followee.PrecomputedIsFollowing ?? false,
|
||||||
|
Blocking = followee.PrecomputedIsBlockedBy ?? false,
|
||||||
|
BlockedBy = followee.PrecomputedIsBlocking ?? false,
|
||||||
|
Requested = followee.PrecomputedIsRequestedBy ?? false,
|
||||||
|
RequestedBy = followee.PrecomputedIsRequested ?? false,
|
||||||
|
Muting = followee.PrecomputedIsMutedBy ?? false,
|
||||||
|
Endorsed = false, //FIXME
|
||||||
|
Note = "", //FIXME
|
||||||
|
Notifying = false, //FIXME
|
||||||
|
DomainBlocking = false, //FIXME
|
||||||
|
MutingNotifications = false, //FIXME
|
||||||
|
ShowingReblogs = true, //FIXME
|
||||||
|
};
|
||||||
|
|
||||||
|
return Ok(res);
|
||||||
|
}
|
||||||
}
|
}
|
|
@ -0,0 +1,29 @@
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using Iceshrimp.Backend.Core.Database;
|
||||||
|
using J = System.Text.Json.Serialization.JsonPropertyNameAttribute;
|
||||||
|
using JI = System.Text.Json.Serialization.JsonIgnoreAttribute;
|
||||||
|
|
||||||
|
namespace Iceshrimp.Backend.Controllers.Mastodon.Schemas.Entities;
|
||||||
|
|
||||||
|
public class Relationship : IEntity {
|
||||||
|
[J("following")] public required bool Following { get; set; }
|
||||||
|
[J("followed_by")] public required bool FollowedBy { get; set; }
|
||||||
|
[J("blocking")] public required bool Blocking { get; set; }
|
||||||
|
[J("blocked_by")] public required bool BlockedBy { get; set; }
|
||||||
|
[J("requested")] public required bool Requested { get; set; }
|
||||||
|
[J("requested_by")] public required bool RequestedBy { get; set; }
|
||||||
|
[J("muting")] public required bool Muting { get; set; }
|
||||||
|
[J("muting_notifications")] public required bool MutingNotifications { get; set; }
|
||||||
|
[J("domain_blocking")] public required bool DomainBlocking { get; set; }
|
||||||
|
[J("endorsed")] public required bool Endorsed { get; set; }
|
||||||
|
[J("showing_reblogs")] public required bool ShowingReblogs { get; set; }
|
||||||
|
[J("notifying")] public required bool Notifying { get; set; }
|
||||||
|
[J("note")] public required string Note { get; set; }
|
||||||
|
|
||||||
|
//TODO: implement this
|
||||||
|
[J("languages")]
|
||||||
|
[JI(Condition = JsonIgnoreCondition.WhenWritingDefault)]
|
||||||
|
public List<string>? Languages { get; set; }
|
||||||
|
|
||||||
|
[J("id")] public required string Id { get; set; }
|
||||||
|
}
|
|
@ -319,6 +319,11 @@ public class User : IEntity {
|
||||||
[InverseProperty(nameof(FollowRequest.Follower))]
|
[InverseProperty(nameof(FollowRequest.Follower))]
|
||||||
public virtual ICollection<FollowRequest> OutgoingFollowRequests { get; set; } = new List<FollowRequest>();
|
public virtual ICollection<FollowRequest> OutgoingFollowRequests { get; set; } = new List<FollowRequest>();
|
||||||
|
|
||||||
|
[Projectable]
|
||||||
|
public virtual IEnumerable<User> ReceivedFollowRequests => IncomingFollowRequests.Select(p => p.Follower);
|
||||||
|
|
||||||
|
[Projectable] public virtual IEnumerable<User> SentFollowRequests => OutgoingFollowRequests.Select(p => p.Followee);
|
||||||
|
|
||||||
[InverseProperty(nameof(Tables.Following.Followee))]
|
[InverseProperty(nameof(Tables.Following.Followee))]
|
||||||
public virtual ICollection<Following> IncomingFollowRelationships { get; set; } = new List<Following>();
|
public virtual ICollection<Following> IncomingFollowRelationships { get; set; } = new List<Following>();
|
||||||
|
|
||||||
|
@ -451,6 +456,18 @@ public class User : IEntity {
|
||||||
[InverseProperty(nameof(Webhook.User))]
|
[InverseProperty(nameof(Webhook.User))]
|
||||||
public virtual ICollection<Webhook> Webhooks { get; set; } = new List<Webhook>();
|
public virtual ICollection<Webhook> Webhooks { get; set; } = new List<Webhook>();
|
||||||
|
|
||||||
|
[NotMapped] public bool? PrecomputedIsBlocking { get; set; }
|
||||||
|
[NotMapped] public bool? PrecomputedIsBlockedBy { get; set; }
|
||||||
|
|
||||||
|
[NotMapped] public bool? PrecomputedIsMuting { get; set; }
|
||||||
|
[NotMapped] public bool? PrecomputedIsMutedBy { get; set; }
|
||||||
|
|
||||||
|
[NotMapped] public bool? PrecomputedIsFollowing { get; set; }
|
||||||
|
[NotMapped] public bool? PrecomputedIsFollowedBy { get; set; }
|
||||||
|
|
||||||
|
[NotMapped] public bool? PrecomputedIsRequested { get; set; }
|
||||||
|
[NotMapped] public bool? PrecomputedIsRequestedBy { get; set; }
|
||||||
|
|
||||||
[Key]
|
[Key]
|
||||||
[Column("id")]
|
[Column("id")]
|
||||||
[StringLength(32)]
|
[StringLength(32)]
|
||||||
|
@ -468,9 +485,38 @@ public class User : IEntity {
|
||||||
[Projectable]
|
[Projectable]
|
||||||
public bool IsFollowing(User user) => Following.Contains(user);
|
public bool IsFollowing(User user) => Following.Contains(user);
|
||||||
|
|
||||||
|
[Projectable]
|
||||||
|
public bool IsRequestedBy(User user) => ReceivedFollowRequests.Contains(user);
|
||||||
|
|
||||||
|
[Projectable]
|
||||||
|
public bool IsRequested(User user) => SentFollowRequests.Contains(user);
|
||||||
|
|
||||||
[Projectable]
|
[Projectable]
|
||||||
public bool IsMutedBy(User user) => MutedBy.Contains(user);
|
public bool IsMutedBy(User user) => MutedBy.Contains(user);
|
||||||
|
|
||||||
[Projectable]
|
[Projectable]
|
||||||
public bool IsMuting(User user) => Muting.Contains(user);
|
public bool IsMuting(User user) => Muting.Contains(user);
|
||||||
|
|
||||||
|
public User WithPrecomputedBlockStatus(bool blocking, bool blockedBy) {
|
||||||
|
PrecomputedIsBlocking = blocking;
|
||||||
|
PrecomputedIsBlockedBy = blockedBy;
|
||||||
|
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public User WithPrecomputedMuteStatus(bool muting, bool mutedBy) {
|
||||||
|
PrecomputedIsMuting = muting;
|
||||||
|
PrecomputedIsMutedBy = mutedBy;
|
||||||
|
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public User WithPrecomputedFollowStatus(bool following, bool followedBy, bool requested, bool requestedBy) {
|
||||||
|
PrecomputedIsFollowing = following;
|
||||||
|
PrecomputedIsFollowedBy = followedBy;
|
||||||
|
PrecomputedIsRequested = requested;
|
||||||
|
PrecomputedIsRequestedBy = requestedBy;
|
||||||
|
|
||||||
|
return this;
|
||||||
|
}
|
||||||
}
|
}
|
|
@ -1,3 +1,4 @@
|
||||||
|
using System.Collections;
|
||||||
using Microsoft.AspNetCore.Mvc.ModelBinding;
|
using Microsoft.AspNetCore.Mvc.ModelBinding;
|
||||||
using Microsoft.AspNetCore.Mvc.ModelBinding.Binders;
|
using Microsoft.AspNetCore.Mvc.ModelBinding.Binders;
|
||||||
|
|
||||||
|
@ -11,12 +12,19 @@ public static class ModelBinderProviderExtensions {
|
||||||
ComplexObjectModelBinderProvider complexProvider)
|
ComplexObjectModelBinderProvider complexProvider)
|
||||||
throw new Exception("Failed to set up hybrid model binding provider");
|
throw new Exception("Failed to set up hybrid model binding provider");
|
||||||
|
|
||||||
|
if (providers.Single(provider => provider.GetType() == typeof(CollectionModelBinderProvider)) is not
|
||||||
|
CollectionModelBinderProvider collectionProvider)
|
||||||
|
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);
|
||||||
|
|
||||||
providers.Insert(0, hybridProvider);
|
providers.Insert(0, hybridProvider);
|
||||||
|
providers.Insert(1, queryProvider);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//TODO: this doesn't work with QueryCollectionModelBinderProvider yet
|
||||||
public class HybridModelBinderProvider(
|
public class HybridModelBinderProvider(
|
||||||
IModelBinderProvider bodyProvider,
|
IModelBinderProvider bodyProvider,
|
||||||
IModelBinderProvider complexProvider) : IModelBinderProvider {
|
IModelBinderProvider complexProvider) : IModelBinderProvider {
|
||||||
|
@ -33,10 +41,17 @@ public class HybridModelBinderProvider(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public class HybridModelBinder(
|
public class QueryCollectionModelBinderProvider(IModelBinderProvider provider) : IModelBinderProvider {
|
||||||
IModelBinder? bodyBinder,
|
public IModelBinder? GetBinder(ModelBinderProviderContext context) {
|
||||||
IModelBinder? complexBinder
|
if (context.BindingInfo.BindingSource == null) return null;
|
||||||
) : IModelBinder {
|
if (!context.BindingInfo.BindingSource.CanAcceptDataFrom(BindingSource.Query)) return null;
|
||||||
|
|
||||||
|
var binder = provider.GetBinder(context);
|
||||||
|
return new QueryCollectionModelBinder(binder);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class HybridModelBinder(IModelBinder? bodyBinder, IModelBinder? complexBinder) : IModelBinder {
|
||||||
public async Task BindModelAsync(ModelBindingContext bindingContext) {
|
public async Task BindModelAsync(ModelBindingContext bindingContext) {
|
||||||
if (bodyBinder != null && bindingContext is
|
if (bodyBinder != null && bindingContext is
|
||||||
{ IsTopLevelObject: true, HttpContext.Request: { HasFormContentType: false, ContentLength: > 0 } }) {
|
{ IsTopLevelObject: true, HttpContext.Request: { HasFormContentType: false, ContentLength: > 0 } }) {
|
||||||
|
@ -53,6 +68,27 @@ public class HybridModelBinder(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public class QueryCollectionModelBinder(IModelBinder? binder) : IModelBinder {
|
||||||
|
public async Task BindModelAsync(ModelBindingContext bindingContext) {
|
||||||
|
if (binder != null && !bindingContext.Result.IsModelSet) {
|
||||||
|
await binder.BindModelAsync(bindingContext);
|
||||||
|
|
||||||
|
if (!bindingContext.Result.IsModelSet || (bindingContext.Result.Model as IList) is not { Count: > 0 }) {
|
||||||
|
bindingContext.ModelName = bindingContext.ModelName.EndsWith("[]")
|
||||||
|
? bindingContext.ModelName[..^2]
|
||||||
|
: bindingContext.ModelName + "[]";
|
||||||
|
|
||||||
|
await binder.BindModelAsync(bindingContext);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bindingContext.Result.IsModelSet) {
|
||||||
|
bindingContext.Model = bindingContext.Result.Model;
|
||||||
|
bindingContext.BindingSource = BindingSource.ModelBinding;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Property)]
|
[AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Property)]
|
||||||
public class FromHybridAttribute : Attribute, IBindingSourceMetadata {
|
public class FromHybridAttribute : Attribute, IBindingSourceMetadata {
|
||||||
public BindingSource BindingSource => HybridBindingSource.Hybrid;
|
public BindingSource BindingSource => HybridBindingSource.Hybrid;
|
||||||
|
|
|
@ -90,6 +90,13 @@ public static class NoteQueryableExtensions {
|
||||||
p.Renote.IsVisibleFor(user)));
|
p.Renote.IsVisibleFor(user)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static IQueryable<User> PrecomputeRelationshipData(this IQueryable<User> query, User user) {
|
||||||
|
return query.Select(p => p.WithPrecomputedBlockStatus(p.IsBlocking(user), p.IsBlockedBy(user))
|
||||||
|
.WithPrecomputedMuteStatus(p.IsMuting(user), p.IsMutedBy(user))
|
||||||
|
.WithPrecomputedFollowStatus(p.IsFollowing(user), p.IsFollowedBy(user),
|
||||||
|
p.IsRequested(user), p.IsRequestedBy(user)));
|
||||||
|
}
|
||||||
|
|
||||||
public static IQueryable<Note> FilterBlocked(this IQueryable<Note> query, User user) {
|
public static IQueryable<Note> FilterBlocked(this IQueryable<Note> query, User user) {
|
||||||
return query.Where(note => !note.User.IsBlocking(user) && !note.User.IsBlockedBy(user))
|
return query.Where(note => !note.User.IsBlocking(user) && !note.User.IsBlockedBy(user))
|
||||||
.Where(note => note.Renote == null ||
|
.Where(note => note.Renote == null ||
|
||||||
|
|
|
@ -2,6 +2,7 @@ using Iceshrimp.Backend.Core.Database;
|
||||||
using Iceshrimp.Backend.Core.Database.Tables;
|
using Iceshrimp.Backend.Core.Database.Tables;
|
||||||
using Iceshrimp.Backend.Core.Federation.ActivityStreams;
|
using Iceshrimp.Backend.Core.Federation.ActivityStreams;
|
||||||
using Iceshrimp.Backend.Core.Federation.ActivityStreams.Types;
|
using Iceshrimp.Backend.Core.Federation.ActivityStreams.Types;
|
||||||
|
using Iceshrimp.Backend.Core.Middleware;
|
||||||
using Iceshrimp.Backend.Core.Queues;
|
using Iceshrimp.Backend.Core.Queues;
|
||||||
using Iceshrimp.Backend.Core.Services;
|
using Iceshrimp.Backend.Core.Services;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
@ -37,4 +38,23 @@ public class ActivityDeliverService(
|
||||||
UserId = actor.Id
|
UserId = actor.Id
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task DeliverToAsync(ASActivity activity, User actor, User recipient) {
|
||||||
|
var inboxUrl = recipient.Inbox ?? recipient.SharedInbox;
|
||||||
|
if (recipient.Host == null || inboxUrl == null)
|
||||||
|
throw new GracefulException("Refusing to deliver to local user");
|
||||||
|
|
||||||
|
logger.LogDebug("Delivering activity {id} to {recipient}", activity.Id, inboxUrl);
|
||||||
|
if (activity.Actor == null) throw new Exception("Actor must not be null");
|
||||||
|
|
||||||
|
var keypair = await db.UserKeypairs.FirstAsync(p => p.User == actor);
|
||||||
|
var payload = await activity.SignAndCompactAsync(keypair);
|
||||||
|
|
||||||
|
await queueService.DeliverQueue.EnqueueAsync(new DeliverJob {
|
||||||
|
InboxUrl = inboxUrl,
|
||||||
|
Payload = payload,
|
||||||
|
ContentType = "application/activity+json",
|
||||||
|
UserId = actor.Id
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
|
@ -78,7 +78,7 @@ public class ActivityHandlerService(
|
||||||
}
|
}
|
||||||
|
|
||||||
var acceptActivity = activityRenderer.RenderAccept(followeeActor,
|
var acceptActivity = activityRenderer.RenderAccept(followeeActor,
|
||||||
activityRenderer.RenderFollow(followerActor,
|
ActivityRenderer.RenderFollow(followerActor,
|
||||||
followeeActor, requestId));
|
followeeActor, requestId));
|
||||||
var keypair = await db.UserKeypairs.FirstAsync(p => p.User == followee);
|
var keypair = await db.UserKeypairs.FirstAsync(p => p.User == followee);
|
||||||
var payload = await acceptActivity.SignAndCompactAsync(keypair);
|
var payload = await acceptActivity.SignAndCompactAsync(keypair);
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
using Iceshrimp.Backend.Core.Configuration;
|
using Iceshrimp.Backend.Core.Configuration;
|
||||||
|
using Iceshrimp.Backend.Core.Database.Tables;
|
||||||
using Iceshrimp.Backend.Core.Federation.ActivityStreams.Types;
|
using Iceshrimp.Backend.Core.Federation.ActivityStreams.Types;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
@ -25,14 +26,17 @@ public class ActivityRenderer(IOptions<Config.InstanceSection> config) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public ASActivity RenderFollow(ASObject followerActor, ASObject followeeActor, string requestId) {
|
public static ASFollow RenderFollow(ASObject followerActor, ASObject followeeActor, string requestId) {
|
||||||
return new ASActivity {
|
return new ASFollow {
|
||||||
Id = requestId,
|
Id = requestId,
|
||||||
Type = ASActivity.Types.Follow,
|
|
||||||
Actor = new ASActor {
|
Actor = new ASActor {
|
||||||
Id = followerActor.Id
|
Id = followerActor.Id
|
||||||
},
|
},
|
||||||
Object = followeeActor
|
Object = followeeActor
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public string RenderFollowId(User follower, User followee) {
|
||||||
|
return $"https://{config.Value.WebDomain}/follows/{follower.Id}/{followee.Id}";
|
||||||
|
}
|
||||||
}
|
}
|
|
@ -12,8 +12,11 @@ namespace Iceshrimp.Backend.Core.Federation.ActivityPub;
|
||||||
|
|
||||||
public class UserRenderer(IOptions<Config.InstanceSection> config, DatabaseContext db) {
|
public class UserRenderer(IOptions<Config.InstanceSection> config, DatabaseContext db) {
|
||||||
public async Task<ASActor> RenderAsync(User user) {
|
public async Task<ASActor> RenderAsync(User user) {
|
||||||
if (user.Host != null)
|
if (user.Host != null) {
|
||||||
throw new GracefulException("Refusing to render remote user");
|
return new ASActor {
|
||||||
|
Id = user.Uri ?? throw new GracefulException("Remote user must have an URI")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
var profile = await db.UserProfiles.FirstOrDefaultAsync(p => p.User == user);
|
var profile = await db.UserProfiles.FirstOrDefaultAsync(p => p.User == user);
|
||||||
var keypair = await db.UserKeypairs.FirstOrDefaultAsync(p => p.User == user);
|
var keypair = await db.UserKeypairs.FirstOrDefaultAsync(p => p.User == user);
|
||||||
|
|
|
@ -26,4 +26,10 @@ public class ASActivity : ASObject {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public class ASFollow : ASActivity {
|
||||||
|
public ASFollow() => Type = Types.Follow;
|
||||||
|
}
|
||||||
|
|
||||||
|
//TODO: add the rest
|
||||||
|
|
||||||
public sealed class ASActivityConverter : ASSerializer.ListSingleObjectConverter<ASActivity>;
|
public sealed class ASActivityConverter : ASSerializer.ListSingleObjectConverter<ASActivity>;
|
|
@ -51,16 +51,15 @@ public static class MastodonOauthHelpers {
|
||||||
|
|
||||||
public static IEnumerable<string> ExpandScopes(IEnumerable<string> scopes) {
|
public static IEnumerable<string> ExpandScopes(IEnumerable<string> scopes) {
|
||||||
var res = new List<string>();
|
var res = new List<string>();
|
||||||
foreach (var scope in scopes) {
|
foreach (var scope in scopes)
|
||||||
if (scope == "read")
|
if (scope == "read")
|
||||||
res.AddRange(ReadScopes);
|
res.AddRange(ReadScopes);
|
||||||
if (scope == "write")
|
else if (scope == "write")
|
||||||
res.AddRange(WriteScopes);
|
res.AddRange(WriteScopes);
|
||||||
if (scope == "follow")
|
else if (scope == "follow")
|
||||||
res.AddRange(FollowScopes);
|
res.AddRange(FollowScopes);
|
||||||
else
|
else
|
||||||
res.Add(scope);
|
res.Add(scope);
|
||||||
}
|
|
||||||
|
|
||||||
return res.Distinct();
|
return res.Distinct();
|
||||||
}
|
}
|
||||||
|
|
|
@ -27,7 +27,7 @@ public class AuthenticationMiddleware(DatabaseContext db) : IMiddleware {
|
||||||
.Include(p => p.User)
|
.Include(p => p.User)
|
||||||
.ThenInclude(p => p.UserProfile)
|
.ThenInclude(p => p.UserProfile)
|
||||||
.Include(p => p.App)
|
.Include(p => p.App)
|
||||||
.FirstOrDefaultAsync(p => p.Token == header && p.Active);
|
.FirstOrDefaultAsync(p => p.Token == token && p.Active);
|
||||||
|
|
||||||
if (oauthToken == null) {
|
if (oauthToken == null) {
|
||||||
await next(ctx);
|
await next(ctx);
|
||||||
|
|
Loading…
Add table
Reference in a new issue