✨ ActivityPub actions
This commit is contained in:
@@ -1,8 +1,5 @@
|
||||
using DysonNetwork.Shared.Models;
|
||||
using DysonNetwork.Sphere.ActivityPub;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NodaTime;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
|
||||
416
DysonNetwork.Sphere/ActivityPub/ActivityPubFollowController.cs
Normal file
416
DysonNetwork.Sphere/ActivityPub/ActivityPubFollowController.cs
Normal file
@@ -0,0 +1,416 @@
|
||||
using DysonNetwork.Shared.Models;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NodaTime;
|
||||
|
||||
namespace DysonNetwork.Sphere.ActivityPub;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/activitypub")]
|
||||
[Authorize]
|
||||
public class ActivityPubFollowController(
|
||||
AppDatabase db,
|
||||
ActivityPubDeliveryService deliveryService,
|
||||
IConfiguration configuration,
|
||||
ILogger<ActivityPubFollowController> logger
|
||||
) : ControllerBase
|
||||
{
|
||||
private string Domain => configuration["ActivityPub:Domain"] ?? "localhost";
|
||||
|
||||
[HttpPost("follow")]
|
||||
public async Task<ActionResult> FollowRemoteUser([FromBody] FollowRequest request)
|
||||
{
|
||||
var currentUser = GetCurrentUser();
|
||||
if (currentUser == null)
|
||||
return Unauthorized(new { error = "Not authenticated" });
|
||||
|
||||
var publisher = await db.Publishers
|
||||
.Include(p => p.Members)
|
||||
.Where(p => p.Members.Any(m => m.AccountId == currentUser))
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
if (publisher == null)
|
||||
return BadRequest(new { error = "User doesn't have a publisher" });
|
||||
|
||||
logger.LogInformation("User {UserId} wants to follow {TargetActor}",
|
||||
currentUser, request.TargetActorUri);
|
||||
|
||||
var success = await deliveryService.SendFollowActivityAsync(
|
||||
publisher.Id,
|
||||
request.TargetActorUri
|
||||
);
|
||||
|
||||
if (success)
|
||||
{
|
||||
return Ok(new
|
||||
{
|
||||
success = true,
|
||||
message = "Follow request sent. Waiting for acceptance.",
|
||||
targetActorUri = request.TargetActorUri
|
||||
});
|
||||
}
|
||||
|
||||
return BadRequest(new { error = "Failed to send follow request" });
|
||||
}
|
||||
|
||||
[HttpPost("unfollow")]
|
||||
public async Task<ActionResult> UnfollowRemoteUser([FromBody] UnfollowRequest request)
|
||||
{
|
||||
var currentUser = GetCurrentUser();
|
||||
if (currentUser == null)
|
||||
return Unauthorized(new { error = "Not authenticated" });
|
||||
|
||||
var publisher = await db.Publishers
|
||||
.Include(p => p.Members)
|
||||
.Where(p => p.Members.Any(m => m.AccountId == currentUser))
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
if (publisher == null)
|
||||
return BadRequest(new { error = "User doesn't have a publisher" });
|
||||
|
||||
var success = await deliveryService.SendUndoActivityAsync(
|
||||
"Follow",
|
||||
request.TargetActorUri,
|
||||
publisher.Id
|
||||
);
|
||||
|
||||
if (success)
|
||||
{
|
||||
return Ok(new
|
||||
{
|
||||
success = true,
|
||||
message = "Unfollowed successfully"
|
||||
});
|
||||
}
|
||||
|
||||
return BadRequest(new { error = "Failed to unfollow" });
|
||||
}
|
||||
|
||||
[HttpGet("following")]
|
||||
public async Task<ActionResult<List<SnFediverseActor>>> GetFollowing(
|
||||
[FromQuery] int limit = 50
|
||||
)
|
||||
{
|
||||
var currentUser = GetCurrentUser();
|
||||
if (currentUser == null)
|
||||
return Unauthorized();
|
||||
|
||||
var publisher = await db.Publishers
|
||||
.Include(p => p.Members)
|
||||
.Where(p => p.Members.Any(m => m.AccountId == currentUser))
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
if (publisher == null)
|
||||
return Ok(new List<SnFediverseActor>());
|
||||
|
||||
var actors = await db.FediverseRelationships
|
||||
.Include(r => r.TargetActor)
|
||||
.Where(r =>
|
||||
r.IsLocalActor &&
|
||||
r.LocalPublisherId == publisher.Id &&
|
||||
r.IsFollowing &&
|
||||
r.State == RelationshipState.Accepted)
|
||||
.OrderByDescending(r => r.FollowedAt)
|
||||
.Select(r => r.TargetActor)
|
||||
.Take(limit)
|
||||
.ToListAsync();
|
||||
|
||||
return Ok(actors);
|
||||
}
|
||||
|
||||
[HttpGet("followers")]
|
||||
public async Task<ActionResult<List<SnFediverseActor>>> GetFollowers(
|
||||
[FromQuery] int limit = 50
|
||||
)
|
||||
{
|
||||
var currentUser = GetCurrentUser();
|
||||
if (currentUser == null)
|
||||
return Unauthorized();
|
||||
|
||||
var publisher = await db.Publishers
|
||||
.Include(p => p.Members)
|
||||
.Where(p => p.Members.Any(m => m.AccountId == currentUser))
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
if (publisher == null)
|
||||
return Ok(new List<SnFediverseActor>());
|
||||
|
||||
var actors = await db.FediverseRelationships
|
||||
.Include(r => r.Actor)
|
||||
.Where(r =>
|
||||
!r.IsLocalActor &&
|
||||
r.LocalPublisherId == publisher.Id &&
|
||||
r.IsFollowedBy &&
|
||||
r.State == RelationshipState.Accepted)
|
||||
.OrderByDescending(r => r.FollowedAt ?? r.CreatedAt)
|
||||
.Select(r => r.Actor)
|
||||
.Take(limit)
|
||||
.ToListAsync();
|
||||
|
||||
return Ok(actors);
|
||||
}
|
||||
|
||||
[HttpGet("search")]
|
||||
public async Task<ActionResult<List<SnFediverseActor>>> SearchRemoteUsers(
|
||||
[FromQuery] string query,
|
||||
[FromQuery] int limit = 20
|
||||
)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(query))
|
||||
return BadRequest(new { error = "Query is required" });
|
||||
|
||||
var actors = await db.FediverseActors
|
||||
.Where(a =>
|
||||
a.Username.Contains(query) ||
|
||||
a.DisplayName != null && a.DisplayName.Contains(query))
|
||||
.OrderByDescending(a => a.LastActivityAt ?? a.CreatedAt)
|
||||
.Take(limit)
|
||||
.ToListAsync();
|
||||
|
||||
return Ok(actors);
|
||||
}
|
||||
|
||||
[HttpGet("relationships")]
|
||||
public async Task<ActionResult<RelationshipsSummary>> GetRelationships()
|
||||
{
|
||||
var currentUser = GetCurrentUser();
|
||||
if (currentUser == null)
|
||||
return Unauthorized();
|
||||
|
||||
var publisher = await db.Publishers
|
||||
.Include(p => p.Members)
|
||||
.Where(p => p.Members.Any(m => m.AccountId == currentUser))
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
if (publisher == null)
|
||||
return NotFound(new { error = "Publisher not found" });
|
||||
|
||||
var actorUrl = $"https://{Domain}/activitypub/actors/{publisher.Name}";
|
||||
|
||||
var followingCount = await db.FediverseRelationships
|
||||
.CountAsync(r =>
|
||||
r.IsLocalActor &&
|
||||
r.LocalPublisherId == publisher.Id &&
|
||||
r.IsFollowing &&
|
||||
r.State == RelationshipState.Accepted);
|
||||
|
||||
var followersCount = await db.FediverseRelationships
|
||||
.CountAsync(r =>
|
||||
!r.IsLocalActor &&
|
||||
r.LocalPublisherId == publisher.Id &&
|
||||
r.IsFollowedBy &&
|
||||
r.State == RelationshipState.Accepted);
|
||||
|
||||
var pendingCount = await db.FediverseRelationships
|
||||
.CountAsync(r =>
|
||||
r.IsLocalActor &&
|
||||
r.LocalPublisherId == publisher.Id &&
|
||||
r.State == RelationshipState.Pending);
|
||||
|
||||
var relationships = await db.FediverseRelationships
|
||||
.Include(r => r.TargetActor)
|
||||
.Where(r => r.IsLocalActor && r.LocalPublisherId == publisher.Id)
|
||||
.OrderByDescending(r => r.FollowedAt ?? r.CreatedAt)
|
||||
.Take(20)
|
||||
.ToListAsync();
|
||||
|
||||
return Ok(new RelationshipsSummary
|
||||
{
|
||||
ActorUri = actorUrl,
|
||||
FollowingCount = followingCount,
|
||||
FollowersCount = followersCount,
|
||||
PendingCount = pendingCount,
|
||||
Relationships = relationships.Select(r => new RelationshipSummaryItem
|
||||
{
|
||||
Actor = r.TargetActor,
|
||||
State = r.State,
|
||||
IsFollowing = r.IsFollowing,
|
||||
FollowedAt = r.FollowedAt,
|
||||
TargetActorUri = r.TargetActor.Uri,
|
||||
Username = r.TargetActor.Username,
|
||||
DisplayName = r.TargetActor.DisplayName
|
||||
}).ToList()
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("check/{username}")]
|
||||
[AllowAnonymous]
|
||||
public async Task<ActionResult<ActorCheckResult>> CheckActor(string username)
|
||||
{
|
||||
var actorUrl = GetActorUrl(username);
|
||||
|
||||
var existingActor = await db.FediverseActors
|
||||
.Include(snFediverseActor => snFediverseActor.Instance)
|
||||
.FirstOrDefaultAsync(a => a.Uri == actorUrl);
|
||||
|
||||
if (existingActor != null)
|
||||
{
|
||||
return Ok(new ActorCheckResult
|
||||
{
|
||||
Exists = true,
|
||||
Actor = existingActor,
|
||||
ActorUri = existingActor.Uri,
|
||||
Username = existingActor.Username,
|
||||
DisplayName = existingActor.DisplayName,
|
||||
Bio = existingActor.Bio,
|
||||
AvatarUrl = existingActor.AvatarUrl,
|
||||
InstanceDomain = existingActor.Instance.Domain,
|
||||
PublicKeyExists = !string.IsNullOrEmpty(existingActor.PublicKey),
|
||||
LastActivityAt = existingActor.LastActivityAt,
|
||||
IsLocal = false
|
||||
});
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var httpClient = new HttpClient();
|
||||
var response = await httpClient.GetAsync(actorUrl);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
return Ok(new ActorCheckResult
|
||||
{
|
||||
Exists = false,
|
||||
ActorUri = actorUrl,
|
||||
Error = $"Actor not accessible: {response.StatusCode}"
|
||||
});
|
||||
}
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
var actorData = System.Text.Json.JsonDocument.Parse(json);
|
||||
|
||||
var preferredUsername = actorData.RootElement.GetProperty("preferredUsername").GetString();
|
||||
var displayName = actorData.RootElement.TryGetProperty("name", out var nameProp)
|
||||
? nameProp.GetString()
|
||||
: null;
|
||||
var bio = actorData.RootElement.TryGetProperty("summary", out var bioProp)
|
||||
? bioProp.GetString()
|
||||
: null;
|
||||
var avatarUrl = actorData.RootElement.TryGetProperty("icon", out var iconProp)
|
||||
? iconProp.GetProperty("url").GetString()
|
||||
: null;
|
||||
var publicKeyPem = actorData.RootElement.GetProperty("publicKey")
|
||||
.GetProperty("publicKeyPem").GetString();
|
||||
|
||||
var domain = ExtractDomain(actorUrl);
|
||||
var instance = await db.FediverseInstances
|
||||
.FirstOrDefaultAsync(i => i.Domain == domain);
|
||||
|
||||
if (instance == null)
|
||||
{
|
||||
instance = new SnFediverseInstance
|
||||
{
|
||||
Domain = domain,
|
||||
Name = domain
|
||||
};
|
||||
db.FediverseInstances.Add(instance);
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var actor = new SnFediverseActor
|
||||
{
|
||||
Uri = actorUrl,
|
||||
Username = username,
|
||||
DisplayName = displayName,
|
||||
Bio = bio,
|
||||
AvatarUrl = avatarUrl,
|
||||
PublicKey = publicKeyPem,
|
||||
InstanceId = instance.Id
|
||||
};
|
||||
|
||||
db.FediverseActors.Add(actor);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Ok(new ActorCheckResult
|
||||
{
|
||||
Exists = true,
|
||||
Actor = actor,
|
||||
ActorUri = actorUrl,
|
||||
Username = username,
|
||||
DisplayName = displayName,
|
||||
Bio = bio,
|
||||
AvatarUrl = avatarUrl,
|
||||
InstanceDomain = domain,
|
||||
PublicKeyExists = !string.IsNullOrEmpty(publicKeyPem),
|
||||
IsDiscoverable = true,
|
||||
IsLocal = false,
|
||||
LastActivityAt = SystemClock.Instance.GetCurrentInstant()
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to check actor {ActorUri}", actorUrl);
|
||||
return Ok(new ActorCheckResult
|
||||
{
|
||||
Exists = false,
|
||||
ActorUri = actorUrl,
|
||||
Error = ex.Message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private Guid? GetCurrentUser()
|
||||
{
|
||||
HttpContext.Items.TryGetValue("CurrentUser", out var currentUser);
|
||||
return currentUser as Guid?;
|
||||
}
|
||||
|
||||
private string ExtractDomain(string actorUri)
|
||||
{
|
||||
var uri = new Uri(actorUri);
|
||||
return uri.Host;
|
||||
}
|
||||
|
||||
private string GetActorUrl(string username)
|
||||
{
|
||||
return $"https://{Domain}/activitypub/actors/{username}";
|
||||
}
|
||||
}
|
||||
|
||||
public class FollowRequest
|
||||
{
|
||||
public string TargetActorUri { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public class UnfollowRequest
|
||||
{
|
||||
public string TargetActorUri { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public class RelationshipsSummary
|
||||
{
|
||||
public string ActorUri { get; set; } = string.Empty;
|
||||
public int FollowingCount { get; set; }
|
||||
public int FollowersCount { get; set; }
|
||||
public int PendingCount { get; set; }
|
||||
public List<RelationshipSummaryItem> Relationships { get; set; } = new();
|
||||
}
|
||||
|
||||
public class RelationshipSummaryItem
|
||||
{
|
||||
public SnFediverseActor Actor { get; set; } = null!;
|
||||
public RelationshipState State { get; set; }
|
||||
public bool IsFollowing { get; set; }
|
||||
public Instant? FollowedAt { get; set; }
|
||||
public string TargetActorUri { get; set; } = string.Empty;
|
||||
public string Username { get; set; } = string.Empty;
|
||||
public string? DisplayName { get; set; }
|
||||
}
|
||||
|
||||
public class ActorCheckResult
|
||||
{
|
||||
public bool Exists { get; set; }
|
||||
public SnFediverseActor? Actor { get; set; }
|
||||
public string ActorUri { get; set; } = string.Empty;
|
||||
public string Username { get; set; } = string.Empty;
|
||||
public string? DisplayName { get; set; }
|
||||
public string? Bio { get; set; }
|
||||
public string? AvatarUrl { get; set; }
|
||||
public string? InstanceDomain { get; set; }
|
||||
public bool PublicKeyExists { get; set; }
|
||||
public bool IsLocal { get; set; }
|
||||
public bool IsDiscoverable { get; set; }
|
||||
public Instant? LastActivityAt { get; set; }
|
||||
public string? Error { get; set; }
|
||||
}
|
||||
Reference in New Issue
Block a user