⚗️ Activity pub
This commit is contained in:
543
DysonNetwork.Sphere/ActivityPub/ActivityPubActivityProcessor.cs
Normal file
543
DysonNetwork.Sphere/ActivityPub/ActivityPubActivityProcessor.cs
Normal file
@@ -0,0 +1,543 @@
|
||||
using DysonNetwork.Shared.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NodaTime;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace DysonNetwork.Sphere.ActivityPub;
|
||||
|
||||
public class ActivityPubActivityProcessor(
|
||||
AppDatabase db,
|
||||
ActivityPubSignatureService signatureService,
|
||||
ActivityPubDeliveryService deliveryService,
|
||||
ILogger<ActivityPubActivityProcessor> logger
|
||||
)
|
||||
{
|
||||
public async Task<bool> ProcessIncomingActivityAsync(
|
||||
HttpContext context,
|
||||
string username,
|
||||
Dictionary<string, object> activity
|
||||
)
|
||||
{
|
||||
if (!signatureService.VerifyIncomingRequest(context, out var actorUri))
|
||||
{
|
||||
logger.LogWarning("Failed to verify signature for incoming activity");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(actorUri))
|
||||
return false;
|
||||
|
||||
var activityType = activity.GetValueOrDefault("type")?.ToString();
|
||||
logger.LogInformation("Processing activity type: {Type} from actor: {Actor}", activityType, actorUri);
|
||||
|
||||
switch (activityType)
|
||||
{
|
||||
case "Follow":
|
||||
return await ProcessFollowAsync(actorUri, activity);
|
||||
case "Accept":
|
||||
return await ProcessAcceptAsync(actorUri, activity);
|
||||
case "Reject":
|
||||
return await ProcessRejectAsync(actorUri, activity);
|
||||
case "Undo":
|
||||
return await ProcessUndoAsync(actorUri, activity);
|
||||
case "Create":
|
||||
return await ProcessCreateAsync(actorUri, activity);
|
||||
case "Like":
|
||||
return await ProcessLikeAsync(actorUri, activity);
|
||||
case "Announce":
|
||||
return await ProcessAnnounceAsync(actorUri, activity);
|
||||
case "Delete":
|
||||
return await ProcessDeleteAsync(actorUri, activity);
|
||||
case "Update":
|
||||
return await ProcessUpdateAsync(actorUri, activity);
|
||||
default:
|
||||
logger.LogWarning("Unsupported activity type: {Type}", activityType);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<bool> ProcessFollowAsync(string actorUri, Dictionary<string, object> activity)
|
||||
{
|
||||
var objectUri = activity.GetValueOrDefault("object")?.ToString();
|
||||
if (string.IsNullOrEmpty(objectUri))
|
||||
return false;
|
||||
|
||||
var actor = await GetOrCreateActorAsync(actorUri);
|
||||
var targetPublisher = await db.Publishers
|
||||
.FirstOrDefaultAsync(p => p.Name == ExtractUsernameFromUri(objectUri));
|
||||
|
||||
if (targetPublisher == null)
|
||||
{
|
||||
logger.LogWarning("Target publisher not found: {Uri}", objectUri);
|
||||
return false;
|
||||
}
|
||||
|
||||
var existingRelationship = await db.FediverseRelationships
|
||||
.FirstOrDefaultAsync(r =>
|
||||
r.ActorId == actor.Id &&
|
||||
r.TargetActorId == actor.Id &&
|
||||
r.IsLocalActor);
|
||||
|
||||
if (existingRelationship != null && existingRelationship.State == RelationshipState.Accepted)
|
||||
{
|
||||
logger.LogInformation("Follow relationship already exists");
|
||||
return true;
|
||||
}
|
||||
|
||||
if (existingRelationship == null)
|
||||
{
|
||||
existingRelationship = new SnFediverseRelationship
|
||||
{
|
||||
ActorId = actor.Id,
|
||||
TargetActorId = actor.Id,
|
||||
IsLocalActor = true,
|
||||
LocalPublisherId = targetPublisher.Id,
|
||||
State = RelationshipState.Pending,
|
||||
IsFollowing = false,
|
||||
IsFollowedBy = true
|
||||
};
|
||||
db.FediverseRelationships.Add(existingRelationship);
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
await deliveryService.SendAcceptActivityAsync(
|
||||
targetPublisher.Id,
|
||||
actorUri,
|
||||
activity.GetValueOrDefault("id")?.ToString() ?? ""
|
||||
);
|
||||
|
||||
logger.LogInformation("Processed follow from {Actor} to {Target}", actorUri, objectUri);
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task<bool> ProcessAcceptAsync(string actorUri, Dictionary<string, object> activity)
|
||||
{
|
||||
var objectUri = activity.GetValueOrDefault("object")?.ToString();
|
||||
if (string.IsNullOrEmpty(objectUri))
|
||||
return false;
|
||||
|
||||
var actor = await GetOrCreateActorAsync(actorUri);
|
||||
|
||||
var relationship = await db.FediverseRelationships
|
||||
.Include(r => r.Actor)
|
||||
.Include(r => r.TargetActor)
|
||||
.FirstOrDefaultAsync(r =>
|
||||
r.IsLocalActor &&
|
||||
r.TargetActorId == actor.Id &&
|
||||
r.State == RelationshipState.Pending);
|
||||
|
||||
if (relationship == null)
|
||||
{
|
||||
logger.LogWarning("No pending relationship found for accept");
|
||||
return false;
|
||||
}
|
||||
|
||||
relationship.State = RelationshipState.Accepted;
|
||||
relationship.IsFollowing = true;
|
||||
relationship.FollowedAt = SystemClock.Instance.GetCurrentInstant();
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
logger.LogInformation("Processed accept from {Actor}", actorUri);
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task<bool> ProcessRejectAsync(string actorUri, Dictionary<string, object> activity)
|
||||
{
|
||||
var objectUri = activity.GetValueOrDefault("object")?.ToString();
|
||||
if (string.IsNullOrEmpty(objectUri))
|
||||
return false;
|
||||
|
||||
var actor = await GetOrCreateActorAsync(actorUri);
|
||||
|
||||
var relationship = await db.FediverseRelationships
|
||||
.FirstOrDefaultAsync(r =>
|
||||
r.IsLocalActor &&
|
||||
r.TargetActorId == actor.Id);
|
||||
|
||||
if (relationship == null)
|
||||
{
|
||||
logger.LogWarning("No relationship found for reject");
|
||||
return false;
|
||||
}
|
||||
|
||||
relationship.State = RelationshipState.Rejected;
|
||||
relationship.IsFollowing = false;
|
||||
relationship.RejectReason = "Remote rejected follow";
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
logger.LogInformation("Processed reject from {Actor}", actorUri);
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task<bool> ProcessUndoAsync(string actorUri, Dictionary<string, object> activity)
|
||||
{
|
||||
var objectValue = activity.GetValueOrDefault("object");
|
||||
if (objectValue == null)
|
||||
return false;
|
||||
|
||||
var objectDict = objectValue as Dictionary<string, object>;
|
||||
if (objectDict != null)
|
||||
{
|
||||
var objectType = objectDict.GetValueOrDefault("type")?.ToString();
|
||||
switch (objectType)
|
||||
{
|
||||
case "Follow":
|
||||
return await UndoFollowAsync(actorUri, objectDict.GetValueOrDefault("id")?.ToString());
|
||||
case "Like":
|
||||
return await UndoLikeAsync(actorUri, objectDict.GetValueOrDefault("id")?.ToString());
|
||||
case "Announce":
|
||||
return await UndoAnnounceAsync(actorUri, objectDict.GetValueOrDefault("id")?.ToString());
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private async Task<bool> ProcessCreateAsync(string actorUri, Dictionary<string, object> activity)
|
||||
{
|
||||
var objectValue = activity.GetValueOrDefault("object");
|
||||
if (objectValue == null || !(objectValue is Dictionary<string, object> objectDict))
|
||||
return false;
|
||||
|
||||
var objectType = objectDict.GetValueOrDefault("type")?.ToString();
|
||||
if (objectType != "Note" && objectType != "Article")
|
||||
{
|
||||
logger.LogInformation("Skipping non-note content type: {Type}", objectType);
|
||||
return true;
|
||||
}
|
||||
|
||||
var actor = await GetOrCreateActorAsync(actorUri);
|
||||
var instance = await GetOrCreateInstanceAsync(actorUri);
|
||||
|
||||
var contentUri = objectDict.GetValueOrDefault("id")?.ToString();
|
||||
if (string.IsNullOrEmpty(contentUri))
|
||||
return false;
|
||||
|
||||
var existingContent = await db.FediverseContents
|
||||
.FirstOrDefaultAsync(c => c.Uri == contentUri);
|
||||
|
||||
if (existingContent != null)
|
||||
{
|
||||
logger.LogInformation("Content already exists: {Uri}", contentUri);
|
||||
return true;
|
||||
}
|
||||
|
||||
var content = new SnFediverseContent
|
||||
{
|
||||
Uri = contentUri,
|
||||
Type = objectType == "Article" ? FediverseContentType.Article : FediverseContentType.Note,
|
||||
Title = objectDict.GetValueOrDefault("name")?.ToString(),
|
||||
Summary = objectDict.GetValueOrDefault("summary")?.ToString(),
|
||||
Content = objectDict.GetValueOrDefault("content")?.ToString(),
|
||||
ContentHtml = objectDict.GetValueOrDefault("contentMap")?.ToString(),
|
||||
PublishedAt = ParseInstant(objectDict.GetValueOrDefault("published")),
|
||||
EditedAt = ParseInstant(objectDict.GetValueOrDefault("updated")),
|
||||
IsSensitive = bool.TryParse(objectDict.GetValueOrDefault("sensitive")?.ToString(), out var sensitive) && sensitive,
|
||||
ActorId = actor.Id,
|
||||
InstanceId = instance.Id,
|
||||
Attachments = ParseAttachments(objectDict.GetValueOrDefault("attachment")),
|
||||
Mentions = ParseMentions(objectDict.GetValueOrDefault("tag")),
|
||||
Tags = ParseTags(objectDict.GetValueOrDefault("tag")),
|
||||
InReplyTo = objectDict.GetValueOrDefault("inReplyTo")?.ToString()
|
||||
};
|
||||
|
||||
db.FediverseContents.Add(content);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
logger.LogInformation("Created federated content: {Uri}", contentUri);
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task<bool> ProcessLikeAsync(string actorUri, Dictionary<string, object> activity)
|
||||
{
|
||||
var objectUri = activity.GetValueOrDefault("object")?.ToString();
|
||||
if (string.IsNullOrEmpty(objectUri))
|
||||
return false;
|
||||
|
||||
var actor = await GetOrCreateActorAsync(actorUri);
|
||||
var content = await db.FediverseContents
|
||||
.FirstOrDefaultAsync(c => c.Uri == objectUri);
|
||||
|
||||
if (content == null)
|
||||
{
|
||||
logger.LogWarning("Content not found for like: {Uri}", objectUri);
|
||||
return false;
|
||||
}
|
||||
|
||||
var existingReaction = await db.FediverseReactions
|
||||
.FirstOrDefaultAsync(r =>
|
||||
r.ActorId == actor.Id &&
|
||||
r.ContentId == content.Id &&
|
||||
r.Type == FediverseReactionType.Like);
|
||||
|
||||
if (existingReaction != null)
|
||||
{
|
||||
logger.LogInformation("Like already exists");
|
||||
return true;
|
||||
}
|
||||
|
||||
var reaction = new SnFediverseReaction
|
||||
{
|
||||
Uri = activity.GetValueOrDefault("id")?.ToString() ?? Guid.NewGuid().ToString(),
|
||||
Type = FediverseReactionType.Like,
|
||||
IsLocal = false,
|
||||
ContentId = content.Id,
|
||||
ActorId = actor.Id
|
||||
};
|
||||
|
||||
db.FediverseReactions.Add(reaction);
|
||||
content.LikeCount++;
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
logger.LogInformation("Processed like from {Actor}", actorUri);
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task<bool> ProcessAnnounceAsync(string actorUri, Dictionary<string, object> activity)
|
||||
{
|
||||
var objectUri = activity.GetValueOrDefault("object")?.ToString();
|
||||
if (string.IsNullOrEmpty(objectUri))
|
||||
return false;
|
||||
|
||||
var actor = await GetOrCreateActorAsync(actorUri);
|
||||
var content = await db.FediverseContents
|
||||
.FirstOrDefaultAsync(c => c.Uri == objectUri);
|
||||
|
||||
if (content != null)
|
||||
{
|
||||
content.BoostCount++;
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
logger.LogInformation("Processed announce from {Actor}", actorUri);
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task<bool> ProcessDeleteAsync(string actorUri, Dictionary<string, object> activity)
|
||||
{
|
||||
var objectUri = activity.GetValueOrDefault("object")?.ToString();
|
||||
if (string.IsNullOrEmpty(objectUri))
|
||||
return false;
|
||||
|
||||
var content = await db.FediverseContents
|
||||
.FirstOrDefaultAsync(c => c.Uri == objectUri);
|
||||
|
||||
if (content != null)
|
||||
{
|
||||
content.DeletedAt = SystemClock.Instance.GetCurrentInstant();
|
||||
await db.SaveChangesAsync();
|
||||
logger.LogInformation("Deleted federated content: {Uri}", objectUri);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task<bool> ProcessUpdateAsync(string actorUri, Dictionary<string, object> activity)
|
||||
{
|
||||
var objectUri = activity.GetValueOrDefault("object")?.ToString();
|
||||
if (string.IsNullOrEmpty(objectUri))
|
||||
return false;
|
||||
|
||||
var content = await db.FediverseContents
|
||||
.FirstOrDefaultAsync(c => c.Uri == objectUri);
|
||||
|
||||
if (content != null)
|
||||
{
|
||||
content.EditedAt = SystemClock.Instance.GetCurrentInstant();
|
||||
content.UpdatedAt = SystemClock.Instance.GetCurrentInstant();
|
||||
await db.SaveChangesAsync();
|
||||
logger.LogInformation("Updated federated content: {Uri}", objectUri);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task<bool> UndoFollowAsync(string actorUri, string? activityId)
|
||||
{
|
||||
var actor = await GetOrCreateActorAsync(actorUri);
|
||||
|
||||
var relationship = await db.FediverseRelationships
|
||||
.FirstOrDefaultAsync(r =>
|
||||
r.ActorId == actor.Id ||
|
||||
r.TargetActorId == actor.Id);
|
||||
|
||||
if (relationship != null)
|
||||
{
|
||||
relationship.IsFollowing = false;
|
||||
relationship.IsFollowedBy = false;
|
||||
await db.SaveChangesAsync();
|
||||
logger.LogInformation("Undid follow relationship");
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task<bool> UndoLikeAsync(string actorUri, string? activityId)
|
||||
{
|
||||
var actor = await GetOrCreateActorAsync(actorUri);
|
||||
|
||||
var reactions = await db.FediverseReactions
|
||||
.Where(r => r.ActorId == actor.Id && r.Type == FediverseReactionType.Like)
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var reaction in reactions)
|
||||
{
|
||||
var content = await db.FediverseContents.FindAsync(reaction.ContentId);
|
||||
if (content != null)
|
||||
{
|
||||
content.LikeCount--;
|
||||
}
|
||||
db.FediverseReactions.Remove(reaction);
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task<bool> UndoAnnounceAsync(string actorUri, string? activityId)
|
||||
{
|
||||
var content = await db.FediverseContents
|
||||
.FirstOrDefaultAsync(c => c.Uri == activityId);
|
||||
|
||||
if (content != null)
|
||||
{
|
||||
content.BoostCount = Math.Max(0, content.BoostCount - 1);
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task<SnFediverseActor> GetOrCreateActorAsync(string actorUri)
|
||||
{
|
||||
var actor = await db.FediverseActors
|
||||
.FirstOrDefaultAsync(a => a.Uri == actorUri);
|
||||
|
||||
if (actor == null)
|
||||
{
|
||||
var instance = await GetOrCreateInstanceAsync(actorUri);
|
||||
actor = new SnFediverseActor
|
||||
{
|
||||
Uri = actorUri,
|
||||
Username = ExtractUsernameFromUri(actorUri),
|
||||
DisplayName = ExtractUsernameFromUri(actorUri),
|
||||
InstanceId = instance.Id
|
||||
};
|
||||
db.FediverseActors.Add(actor);
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
return actor;
|
||||
}
|
||||
|
||||
private async Task<SnFediverseInstance> GetOrCreateInstanceAsync(string actorUri)
|
||||
{
|
||||
var domain = ExtractDomainFromUri(actorUri);
|
||||
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();
|
||||
}
|
||||
|
||||
return instance;
|
||||
}
|
||||
|
||||
private string ExtractUsernameFromUri(string uri)
|
||||
{
|
||||
return uri.Split('/').Last();
|
||||
}
|
||||
|
||||
private string ExtractDomainFromUri(string uri)
|
||||
{
|
||||
var uriObj = new Uri(uri);
|
||||
return uriObj.Host;
|
||||
}
|
||||
|
||||
private Instant? ParseInstant(object? value)
|
||||
{
|
||||
if (value == null)
|
||||
return null;
|
||||
|
||||
if (value is Instant instant)
|
||||
return instant;
|
||||
|
||||
if (DateTimeOffset.TryParse(value.ToString(), out var dateTimeOffset))
|
||||
return Instant.FromDateTimeOffset(dateTimeOffset);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private List<ContentAttachment>? ParseAttachments(object? value)
|
||||
{
|
||||
if (value == null)
|
||||
return null;
|
||||
|
||||
if (value is JsonElement element && element.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
return element.EnumerateArray()
|
||||
.Select(attachment => new ContentAttachment
|
||||
{
|
||||
Url = attachment.GetProperty("url").GetString(),
|
||||
MediaType = attachment.GetProperty("mediaType").GetString(),
|
||||
Name = attachment.GetProperty("name").GetString()
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private List<ContentMention>? ParseMentions(object? value)
|
||||
{
|
||||
if (value == null)
|
||||
return null;
|
||||
|
||||
if (value is JsonElement element && element.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
return element.EnumerateArray()
|
||||
.Where(e => e.GetProperty("type").GetString() == "Mention")
|
||||
.Select(mention => new ContentMention
|
||||
{
|
||||
Username = mention.GetProperty("name").GetString(),
|
||||
ActorUri = mention.GetProperty("href").GetString()
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private List<ContentTag>? ParseTags(object? value)
|
||||
{
|
||||
if (value == null)
|
||||
return null;
|
||||
|
||||
if (value is JsonElement element && element.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
return element.EnumerateArray()
|
||||
.Where(e => e.GetProperty("type").GetString() == "Hashtag")
|
||||
.Select(tag => new ContentTag
|
||||
{
|
||||
Name = tag.GetProperty("name").GetString(),
|
||||
Url = tag.GetProperty("href").GetString()
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
224
DysonNetwork.Sphere/ActivityPub/ActivityPubController.cs
Normal file
224
DysonNetwork.Sphere/ActivityPub/ActivityPubController.cs
Normal file
@@ -0,0 +1,224 @@
|
||||
using System.Net.Mime;
|
||||
using System.Text.Json.Serialization;
|
||||
using DysonNetwork.Shared.Models;
|
||||
using DysonNetwork.Sphere.ActivityPub;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NodaTime;
|
||||
using Swashbuckle.AspNetCore.Annotations;
|
||||
|
||||
namespace DysonNetwork.Sphere.ActivityPub;
|
||||
|
||||
[ApiController]
|
||||
[Route("activitypub")]
|
||||
public class ActivityPubController(
|
||||
AppDatabase db,
|
||||
IConfiguration configuration,
|
||||
ILogger<ActivityPubController> logger,
|
||||
ActivityPubSignatureService signatureService,
|
||||
ActivityPubActivityProcessor activityProcessor,
|
||||
ActivityPubKeyService keyService
|
||||
) : ControllerBase
|
||||
{
|
||||
private string Domain => configuration["ActivityPub:Domain"] ?? "localhost";
|
||||
|
||||
[HttpGet("actors/{username}")]
|
||||
[Produces("application/activity+json")]
|
||||
[ProducesResponseType(typeof(ActivityPubActor), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[SwaggerOperation(
|
||||
Summary = "Get ActivityPub actor",
|
||||
Description = "Returns the ActivityPub actor (user) profile in JSON-LD format",
|
||||
OperationId = "GetActivityPubActor"
|
||||
)]
|
||||
public async Task<ActionResult<ActivityPubActor>> GetActor(string username)
|
||||
{
|
||||
var publisher = await db.Publishers
|
||||
.Include(p => p.Members)
|
||||
.FirstOrDefaultAsync(p => p.Name == username);
|
||||
|
||||
if (publisher == null)
|
||||
return NotFound();
|
||||
|
||||
var actorUrl = $"https://{Domain}/activitypub/actors/{username}";
|
||||
var inboxUrl = $"{actorUrl}/inbox";
|
||||
var outboxUrl = $"{actorUrl}/outbox";
|
||||
var followersUrl = $"{actorUrl}/followers";
|
||||
var followingUrl = $"{actorUrl}/following";
|
||||
|
||||
var actor = new ActivityPubActor
|
||||
{
|
||||
Context = ["https://www.w3.org/ns/activitystreams"],
|
||||
Id = actorUrl,
|
||||
Type = "Person",
|
||||
Name = publisher.Nick,
|
||||
PreferredUsername = publisher.Name,
|
||||
Summary = publisher.Bio,
|
||||
Inbox = inboxUrl,
|
||||
Outbox = outboxUrl,
|
||||
Followers = followersUrl,
|
||||
Following = followingUrl,
|
||||
Published = publisher.CreatedAt,
|
||||
Url = $"https://{Domain}/users/{publisher.Name}",
|
||||
PublicKeys =
|
||||
[
|
||||
new ActivityPubPublicKey
|
||||
{
|
||||
Id = $"{actorUrl}#main-key",
|
||||
Owner = actorUrl,
|
||||
PublicKeyPem = GetPublicKey(publisher, keyService)
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
return Ok(actor);
|
||||
}
|
||||
|
||||
[HttpGet("actors/{username}/inbox")]
|
||||
[Consumes("application/activity+json")]
|
||||
[ProducesResponseType(StatusCodes.Status202Accepted)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[SwaggerOperation(
|
||||
Summary = "Receive ActivityPub activities",
|
||||
Description = "Endpoint for receiving ActivityPub activities (Create, Follow, Like, etc.) from remote servers",
|
||||
OperationId = "ReceiveActivity"
|
||||
)]
|
||||
public async Task<IActionResult> PostInbox(string username, [FromBody] Dictionary<string, object> activity)
|
||||
{
|
||||
if (!signatureService.VerifyIncomingRequest(HttpContext, out var actorUri))
|
||||
{
|
||||
logger.LogWarning("Failed to verify signature for incoming activity");
|
||||
return Unauthorized(new { error = "Invalid signature" });
|
||||
}
|
||||
|
||||
var success = await activityProcessor.ProcessIncomingActivityAsync(HttpContext, username, activity);
|
||||
|
||||
if (!success)
|
||||
{
|
||||
logger.LogWarning("Failed to process activity for actor {Username}", username);
|
||||
return BadRequest(new { error = "Failed to process activity" });
|
||||
}
|
||||
|
||||
logger.LogInformation("Successfully processed activity for actor {Username}: {Type}", username,
|
||||
activity.GetValueOrDefault("type")?.ToString());
|
||||
|
||||
return Accepted();
|
||||
}
|
||||
|
||||
[HttpGet("actors/{username}/outbox")]
|
||||
[Produces("application/activity+json")]
|
||||
[ProducesResponseType(typeof(ActivityPubCollection), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[SwaggerOperation(
|
||||
Summary = "Get ActivityPub outbox",
|
||||
Description = "Returns the actor's outbox collection containing their public activities",
|
||||
OperationId = "GetActorOutbox"
|
||||
)]
|
||||
public async Task<ActionResult<ActivityPubCollection>> GetOutbox(string username)
|
||||
{
|
||||
var publisher = await db.Publishers
|
||||
.FirstOrDefaultAsync(p => p.Name == username);
|
||||
|
||||
if (publisher == null)
|
||||
return NotFound();
|
||||
|
||||
var actorUrl = $"https://{Domain}/activitypub/actors/{username}";
|
||||
var outboxUrl = $"{actorUrl}/outbox";
|
||||
|
||||
var posts = await db.Posts
|
||||
.Where(p => p.PublisherId == publisher.Id && p.Visibility == PostVisibility.Public)
|
||||
.OrderByDescending(p => p.PublishedAt ?? p.CreatedAt)
|
||||
.Take(20)
|
||||
.ToListAsync();
|
||||
|
||||
var items = posts.Select(post => new
|
||||
{
|
||||
id = $"https://{Domain}/activitypub/objects/{post.Id}",
|
||||
type = post.Type == PostType.Article ? "Article" : "Note",
|
||||
published = post.PublishedAt ?? post.CreatedAt,
|
||||
attributedTo = actorUrl,
|
||||
content = post.Content,
|
||||
url = $"https://{Domain}/posts/{post.Id}"
|
||||
}).ToList<object>();
|
||||
|
||||
var collection = new ActivityPubCollection
|
||||
{
|
||||
Context = ["https://www.w3.org/ns/activitystreams"],
|
||||
Id = outboxUrl,
|
||||
Type = "OrderedCollection",
|
||||
TotalItems = items.Count,
|
||||
First = $"{outboxUrl}?page=1"
|
||||
};
|
||||
|
||||
return Ok(collection);
|
||||
}
|
||||
|
||||
private string GetPublicKey(SnPublisher publisher, ActivityPubKeyService keyService)
|
||||
{
|
||||
var publicKeyPem = GetPublisherKey(publisher, "public_key");
|
||||
|
||||
if (string.IsNullOrEmpty(publicKeyPem))
|
||||
{
|
||||
var (newPrivate, newPublic) = keyService.GenerateKeyPair();
|
||||
SavePublisherKey(publisher, "private_key", newPrivate);
|
||||
SavePublisherKey(publisher, "public_key", newPublic);
|
||||
return newPublic;
|
||||
}
|
||||
|
||||
return publicKeyPem;
|
||||
}
|
||||
|
||||
private string? GetPublisherKey(SnPublisher publisher, string keyName)
|
||||
{
|
||||
if (publisher.Meta == null)
|
||||
return null;
|
||||
|
||||
var metadata = publisher.Meta as Dictionary<string, object>;
|
||||
return metadata?.GetValueOrDefault(keyName)?.ToString();
|
||||
}
|
||||
|
||||
private void SavePublisherKey(SnPublisher publisher, string keyName, string keyValue)
|
||||
{
|
||||
publisher.Meta ??= new Dictionary<string, object>();
|
||||
var metadata = publisher.Meta as Dictionary<string, object>;
|
||||
if (metadata != null)
|
||||
{
|
||||
metadata[keyName] = keyValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public class ActivityPubActor
|
||||
{
|
||||
[JsonPropertyName("@context")] public List<object> Context { get; set; } = [];
|
||||
[JsonPropertyName("id")] public string? Id { get; set; }
|
||||
[JsonPropertyName("type")] public string? Type { get; set; }
|
||||
[JsonPropertyName("name")] public string? Name { get; set; }
|
||||
[JsonPropertyName("preferredUsername")] public string? PreferredUsername { get; set; }
|
||||
[JsonPropertyName("summary")] public string? Summary { get; set; }
|
||||
[JsonPropertyName("inbox")] public string? Inbox { get; set; }
|
||||
[JsonPropertyName("outbox")] public string? Outbox { get; set; }
|
||||
[JsonPropertyName("followers")] public string? Followers { get; set; }
|
||||
[JsonPropertyName("following")] public string? Following { get; set; }
|
||||
[JsonPropertyName("published")] public Instant? Published { get; set; }
|
||||
[JsonPropertyName("url")] public string? Url { get; set; }
|
||||
[JsonPropertyName("publicKey")] public List<ActivityPubPublicKey>? PublicKeys { get; set; }
|
||||
}
|
||||
|
||||
public class ActivityPubPublicKey
|
||||
{
|
||||
[JsonPropertyName("id")] public string? Id { get; set; }
|
||||
[JsonPropertyName("owner")] public string? Owner { get; set; }
|
||||
[JsonPropertyName("publicKeyPem")] public string? PublicKeyPem { get; set; }
|
||||
}
|
||||
|
||||
public class ActivityPubCollection
|
||||
{
|
||||
[JsonPropertyName("@context")] public List<object> Context { get; set; } = [];
|
||||
[JsonPropertyName("id")] public string? Id { get; set; }
|
||||
[JsonPropertyName("type")] public string? Type { get; set; }
|
||||
[JsonPropertyName("totalItems")] public int TotalItems { get; set; }
|
||||
[JsonPropertyName("first")] public string? First { get; set; }
|
||||
[JsonPropertyName("items")] public List<object>? Items { get; set; }
|
||||
}
|
||||
348
DysonNetwork.Sphere/ActivityPub/ActivityPubDeliveryService.cs
Normal file
348
DysonNetwork.Sphere/ActivityPub/ActivityPubDeliveryService.cs
Normal file
@@ -0,0 +1,348 @@
|
||||
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;
|
||||
|
||||
namespace DysonNetwork.Sphere.ActivityPub;
|
||||
|
||||
public class ActivityPubDeliveryService(
|
||||
AppDatabase db,
|
||||
ActivityPubSignatureService signatureService,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IConfiguration configuration,
|
||||
ILogger<ActivityPubDeliveryService> logger
|
||||
)
|
||||
{
|
||||
private string Domain => configuration["ActivityPub:Domain"] ?? "localhost";
|
||||
private HttpClient HttpClient => httpClientFactory.CreateClient();
|
||||
|
||||
public async Task<bool> SendAcceptActivityAsync(
|
||||
Guid publisherId,
|
||||
string followerActorUri,
|
||||
string followActivityId
|
||||
)
|
||||
{
|
||||
var publisher = await db.Publishers.FindAsync(publisherId);
|
||||
if (publisher == null)
|
||||
return false;
|
||||
|
||||
var actorUrl = $"https://{Domain}/activitypub/actors/{publisher.Name}";
|
||||
var followerActor = await db.FediverseActors
|
||||
.FirstOrDefaultAsync(a => a.Uri == followerActorUri);
|
||||
|
||||
if (followerActor?.InboxUri == null)
|
||||
{
|
||||
logger.LogWarning("Follower actor or inbox not found: {Uri}", followerActorUri);
|
||||
return false;
|
||||
}
|
||||
|
||||
var activity = new Dictionary<string, object>
|
||||
{
|
||||
["@context"] = "https://www.w3.org/ns/activitystreams",
|
||||
["id"] = $"{actorUrl}/accepts/{Guid.NewGuid()}",
|
||||
["type"] = "Accept",
|
||||
["actor"] = actorUrl,
|
||||
["object"] = followActivityId
|
||||
};
|
||||
|
||||
return await SendActivityToInboxAsync(activity, followerActor.InboxUri, actorUrl);
|
||||
}
|
||||
|
||||
public async Task<bool> SendFollowActivityAsync(
|
||||
Guid publisherId,
|
||||
string targetActorUri
|
||||
)
|
||||
{
|
||||
var publisher = await db.Publishers.FindAsync(publisherId);
|
||||
if (publisher == null)
|
||||
return false;
|
||||
|
||||
var actorUrl = $"https://{Domain}/activitypub/actors/{publisher.Name}";
|
||||
var targetActor = await GetOrFetchActorAsync(targetActorUri);
|
||||
|
||||
if (targetActor?.InboxUri == null)
|
||||
{
|
||||
logger.LogWarning("Target actor or inbox not found: {Uri}", targetActorUri);
|
||||
return false;
|
||||
}
|
||||
|
||||
var activity = new Dictionary<string, object>
|
||||
{
|
||||
["@context"] = "https://www.w3.org/ns/activitystreams",
|
||||
["id"] = $"{actorUrl}/follows/{Guid.NewGuid()}",
|
||||
["type"] = "Follow",
|
||||
["actor"] = actorUrl,
|
||||
["object"] = targetActorUri
|
||||
};
|
||||
|
||||
await db.FediverseRelationships.AddAsync(new SnFediverseRelationship
|
||||
{
|
||||
IsLocalActor = true,
|
||||
LocalPublisherId = publisher.Id,
|
||||
ActorId = Guid.NewGuid(),
|
||||
TargetActorId = targetActor.Id,
|
||||
State = RelationshipState.Pending,
|
||||
IsFollowing = true,
|
||||
IsFollowedBy = false
|
||||
});
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return await SendActivityToInboxAsync(activity, targetActor.InboxUri, actorUrl);
|
||||
}
|
||||
|
||||
public async Task<bool> SendCreateActivityAsync(SnPost post)
|
||||
{
|
||||
var publisher = await db.Publishers.FindAsync(post.PublisherId);
|
||||
if (publisher == null)
|
||||
return false;
|
||||
|
||||
var actorUrl = $"https://{Domain}/activitypub/actors/{publisher.Name}";
|
||||
var postUrl = $"https://{Domain}/posts/{post.Id}";
|
||||
|
||||
var activity = new Dictionary<string, object>
|
||||
{
|
||||
["@context"] = "https://www.w3.org/ns/activitystreams",
|
||||
["id"] = $"{postUrl}/activity",
|
||||
["type"] = "Create",
|
||||
["actor"] = actorUrl,
|
||||
["published"] = (post.PublishedAt ?? post.CreatedAt).ToDateTimeOffset(),
|
||||
["to"] = new[] { "https://www.w3.org/ns/activitystreams#Public" },
|
||||
["cc"] = new[] { $"{actorUrl}/followers" },
|
||||
["object"] = new Dictionary<string, object>
|
||||
{
|
||||
["id"] = postUrl,
|
||||
["type"] = post.Type == PostType.Article ? "Article" : "Note",
|
||||
["published"] = (post.PublishedAt ?? post.CreatedAt).ToDateTimeOffset(),
|
||||
["attributedTo"] = actorUrl,
|
||||
["content"] = post.Content ?? "",
|
||||
["to"] = new[] { "https://www.w3.org/ns/activitystreams#Public" },
|
||||
["cc"] = new[] { $"{actorUrl}/followers" },
|
||||
["attachment"] = post.Attachments.Select(a => new Dictionary<string, object>
|
||||
{
|
||||
["type"] = "Document",
|
||||
["mediaType"] = "image/jpeg",
|
||||
["url"] = $"https://{Domain}/api/files/{a.Id}"
|
||||
}).ToList<object>()
|
||||
}
|
||||
};
|
||||
|
||||
var followers = await GetRemoteFollowersAsync(publisher.Id);
|
||||
var successCount = 0;
|
||||
|
||||
foreach (var follower in followers)
|
||||
{
|
||||
if (follower.InboxUri != null)
|
||||
{
|
||||
var success = await SendActivityToInboxAsync(activity, follower.InboxUri, actorUrl);
|
||||
if (success)
|
||||
successCount++;
|
||||
}
|
||||
}
|
||||
|
||||
logger.LogInformation("Sent Create activity to {Count}/{Total} followers",
|
||||
successCount, followers.Count);
|
||||
|
||||
return successCount > 0;
|
||||
}
|
||||
|
||||
public async Task<bool> SendLikeActivityAsync(
|
||||
Guid postId,
|
||||
Guid accountId,
|
||||
string targetActorUri
|
||||
)
|
||||
{
|
||||
var publisher = await db.Publishers
|
||||
.Include(p => p.Members)
|
||||
.Where(p => p.Members.Any(m => m.AccountId == accountId))
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
if (publisher == null)
|
||||
return false;
|
||||
|
||||
var actorUrl = $"https://{Domain}/activitypub/actors/{publisher.Name}";
|
||||
var postUrl = $"https://{Domain}/posts/{postId}";
|
||||
var targetActor = await GetOrFetchActorAsync(targetActorUri);
|
||||
|
||||
if (targetActor?.InboxUri == null)
|
||||
return false;
|
||||
|
||||
var activity = new Dictionary<string, object>
|
||||
{
|
||||
["@context"] = "https://www.w3.org/ns/activitystreams",
|
||||
["id"] = $"{actorUrl}/likes/{Guid.NewGuid()}",
|
||||
["type"] = "Like",
|
||||
["actor"] = actorUrl,
|
||||
["object"] = postUrl
|
||||
};
|
||||
|
||||
return await SendActivityToInboxAsync(activity, targetActor.InboxUri, actorUrl);
|
||||
}
|
||||
|
||||
public async Task<bool> SendUndoActivityAsync(
|
||||
string activityType,
|
||||
string objectUri,
|
||||
Guid publisherId
|
||||
)
|
||||
{
|
||||
var publisher = await db.Publishers.FindAsync(publisherId);
|
||||
if (publisher == null)
|
||||
return false;
|
||||
|
||||
var actorUrl = $"https://{Domain}/activitypub/actors/{publisher.Name}";
|
||||
var followers = await GetRemoteFollowersAsync(publisher.Id);
|
||||
|
||||
var activity = new Dictionary<string, object>
|
||||
{
|
||||
["@context"] = "https://www.w3.org/ns/activitystreams",
|
||||
["id"] = $"{actorUrl}/undo/{Guid.NewGuid()}",
|
||||
["type"] = "Undo",
|
||||
["actor"] = actorUrl,
|
||||
["object"] = new Dictionary<string, object>
|
||||
{
|
||||
["type"] = activityType,
|
||||
["object"] = objectUri
|
||||
}
|
||||
};
|
||||
|
||||
var successCount = 0;
|
||||
foreach (var follower in followers)
|
||||
{
|
||||
if (follower.InboxUri != null)
|
||||
{
|
||||
var success = await SendActivityToInboxAsync(activity, follower.InboxUri, actorUrl);
|
||||
if (success)
|
||||
successCount++;
|
||||
}
|
||||
}
|
||||
|
||||
return successCount > 0;
|
||||
}
|
||||
|
||||
private async Task<bool> SendActivityToInboxAsync(
|
||||
Dictionary<string, object> activity,
|
||||
string inboxUrl,
|
||||
string actorUri
|
||||
)
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = JsonSerializer.Serialize(activity);
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, inboxUrl);
|
||||
|
||||
request.Content = new StringContent(json, Encoding.UTF8, "application/activity+json");
|
||||
request.Headers.Date = DateTimeOffset.UtcNow;
|
||||
|
||||
var signatureHeaders = await signatureService.SignOutgoingRequest(request, actorUri);
|
||||
var signature = signatureHeaders;
|
||||
|
||||
var signatureString = $"keyId=\"{signature["keyId"]}\"," +
|
||||
$"algorithm=\"{signature["algorithm"]}\"," +
|
||||
$"headers=\"{signature["headers"]}\"," +
|
||||
$"signature=\"{signature["signature"]}\"";
|
||||
|
||||
request.Headers.Add("Signature", signatureString);
|
||||
request.Headers.Add("Host", new Uri(inboxUrl).Host);
|
||||
request.Headers.Add("Content-Type", "application/activity+json");
|
||||
|
||||
var response = await HttpClient.SendAsync(request);
|
||||
var responseContent = await response.Content.ReadAsStringAsync();
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
logger.LogWarning("Failed to send activity to {Inbox}. Status: {Status}, Response: {Response}",
|
||||
inboxUrl, response.StatusCode, responseContent);
|
||||
return false;
|
||||
}
|
||||
|
||||
logger.LogInformation("Successfully sent activity to {Inbox}", inboxUrl);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Error sending activity to {Inbox}", inboxUrl);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<List<SnFediverseActor>> GetRemoteFollowersAsync(Guid publisherId)
|
||||
{
|
||||
return await db.FediverseRelationships
|
||||
.Include(r => r.TargetActor)
|
||||
.Where(r =>
|
||||
r.LocalPublisherId == publisherId &&
|
||||
r.IsFollowedBy &&
|
||||
r.IsLocalActor)
|
||||
.Select(r => r.TargetActor)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
private async Task<SnFediverseActor?> GetOrFetchActorAsync(string actorUri)
|
||||
{
|
||||
var actor = await db.FediverseActors
|
||||
.FirstOrDefaultAsync(a => a.Uri == actorUri);
|
||||
|
||||
if (actor != null)
|
||||
return actor;
|
||||
|
||||
try
|
||||
{
|
||||
var response = await HttpClient.GetAsync(actorUri);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
return null;
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
var actorData = JsonSerializer.Deserialize<Dictionary<string, object>>(json);
|
||||
|
||||
if (actorData == null)
|
||||
return null;
|
||||
|
||||
var domain = new Uri(actorUri).Host;
|
||||
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();
|
||||
}
|
||||
|
||||
actor = new SnFediverseActor
|
||||
{
|
||||
Uri = actorUri,
|
||||
Username = ExtractUsername(actorUri),
|
||||
DisplayName = actorData.GetValueOrDefault("name")?.ToString(),
|
||||
Bio = actorData.GetValueOrDefault("summary")?.ToString(),
|
||||
InboxUri = actorData.GetValueOrDefault("inbox")?.ToString(),
|
||||
OutboxUri = actorData.GetValueOrDefault("outbox")?.ToString(),
|
||||
FollowersUri = actorData.GetValueOrDefault("followers")?.ToString(),
|
||||
FollowingUri = actorData.GetValueOrDefault("following")?.ToString(),
|
||||
AvatarUrl = actorData.GetValueOrDefault("icon")?.ToString(),
|
||||
InstanceId = instance.Id
|
||||
};
|
||||
|
||||
db.FediverseActors.Add(actor);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return actor;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to fetch actor: {Uri}", actorUri);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private string ExtractUsername(string actorUri)
|
||||
{
|
||||
return actorUri.Split('/').Last();
|
||||
}
|
||||
}
|
||||
91
DysonNetwork.Sphere/ActivityPub/ActivityPubKeyService.cs
Normal file
91
DysonNetwork.Sphere/ActivityPub/ActivityPubKeyService.cs
Normal file
@@ -0,0 +1,91 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using System.Text;
|
||||
|
||||
namespace DysonNetwork.Sphere.ActivityPub;
|
||||
|
||||
public class ActivityPubKeyService(ILogger<ActivityPubKeyService> logger)
|
||||
{
|
||||
public (string privateKeyPem, string publicKeyPem) GenerateKeyPair()
|
||||
{
|
||||
using var rsa = RSA.Create(2048);
|
||||
|
||||
var privateKey = rsa.ExportRSAPrivateKey();
|
||||
var publicKey = rsa.ExportRSAPublicKey();
|
||||
|
||||
var privateKeyPem = ConvertToPem(privateKey, "RSA PRIVATE KEY");
|
||||
var publicKeyPem = ConvertToPem(publicKey, "PUBLIC KEY");
|
||||
|
||||
logger.LogInformation("Generated new RSA key pair for ActivityPub");
|
||||
|
||||
return (privateKeyPem, publicKeyPem);
|
||||
}
|
||||
|
||||
public string Sign(string privateKeyPem, string dataToSign)
|
||||
{
|
||||
using var rsa = CreateRsaFromPrivateKeyPem(privateKeyPem);
|
||||
var signature = rsa.SignData(
|
||||
Encoding.UTF8.GetBytes(dataToSign),
|
||||
HashAlgorithmName.SHA256,
|
||||
RSASignaturePadding.Pkcs1
|
||||
);
|
||||
return Convert.ToBase64String(signature);
|
||||
}
|
||||
|
||||
public bool Verify(string publicKeyPem, string data, string signatureBase64)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var rsa = CreateRsaFromPublicKeyPem(publicKeyPem);
|
||||
var signature = Convert.FromBase64String(signatureBase64);
|
||||
return rsa.VerifyData(
|
||||
Encoding.UTF8.GetBytes(data),
|
||||
signature,
|
||||
HashAlgorithmName.SHA256,
|
||||
RSASignaturePadding.Pkcs1
|
||||
);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to verify signature");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static string ConvertToPem(byte[] keyData, string keyType)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine($"-----BEGIN {keyType}-----");
|
||||
sb.AppendLine(Convert.ToBase64String(keyData));
|
||||
sb.AppendLine($"-----END {keyType}-----");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static RSA CreateRsaFromPrivateKeyPem(string privateKeyPem)
|
||||
{
|
||||
var rsa = RSA.Create();
|
||||
|
||||
var lines = privateKeyPem.Split('\n')
|
||||
.Where(line => !line.StartsWith("-----") && !string.IsNullOrWhiteSpace(line))
|
||||
.ToArray();
|
||||
|
||||
var keyBytes = Convert.FromBase64String(string.Join("", lines));
|
||||
rsa.ImportRSAPrivateKey(keyBytes, out _);
|
||||
|
||||
return rsa;
|
||||
}
|
||||
|
||||
private static RSA CreateRsaFromPublicKeyPem(string publicKeyPem)
|
||||
{
|
||||
var rsa = RSA.Create();
|
||||
|
||||
var lines = publicKeyPem.Split('\n')
|
||||
.Where(line => !line.StartsWith("-----") && !string.IsNullOrWhiteSpace(line))
|
||||
.ToArray();
|
||||
|
||||
var keyBytes = Convert.FromBase64String(string.Join("", lines));
|
||||
rsa.ImportRSAPublicKey(keyBytes, out _);
|
||||
|
||||
return rsa;
|
||||
}
|
||||
}
|
||||
230
DysonNetwork.Sphere/ActivityPub/ActivityPubSignatureService.cs
Normal file
230
DysonNetwork.Sphere/ActivityPub/ActivityPubSignatureService.cs
Normal file
@@ -0,0 +1,230 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using DysonNetwork.Shared.Models;
|
||||
using DysonNetwork.Sphere.ActivityPub;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NodaTime;
|
||||
|
||||
namespace DysonNetwork.Sphere.ActivityPub;
|
||||
|
||||
public class ActivityPubSignatureService(
|
||||
AppDatabase db,
|
||||
ActivityPubKeyService keyService,
|
||||
ILogger<ActivityPubSignatureService> logger,
|
||||
IConfiguration configuration
|
||||
)
|
||||
{
|
||||
private string Domain => configuration["ActivityPub:Domain"] ?? "localhost";
|
||||
|
||||
public bool VerifyIncomingRequest(HttpContext context, out string? actorUri)
|
||||
{
|
||||
actorUri = null;
|
||||
|
||||
if (!context.Request.Headers.ContainsKey("Signature"))
|
||||
return false;
|
||||
|
||||
var signatureHeader = context.Request.Headers["Signature"].ToString();
|
||||
var signatureParts = ParseSignatureHeader(signatureHeader);
|
||||
|
||||
if (signatureParts == null)
|
||||
{
|
||||
logger.LogWarning("Invalid signature header format");
|
||||
return false;
|
||||
}
|
||||
|
||||
actorUri = signatureParts.GetValueOrDefault("keyId");
|
||||
if (string.IsNullOrEmpty(actorUri))
|
||||
{
|
||||
logger.LogWarning("No keyId in signature");
|
||||
return false;
|
||||
}
|
||||
|
||||
var actor = GetActorByKeyId(actorUri);
|
||||
if (actor == null)
|
||||
{
|
||||
logger.LogWarning("Actor not found for keyId: {KeyId}", actorUri);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(actor.PublicKey))
|
||||
{
|
||||
logger.LogWarning("Actor has no public key: {ActorId}", actor.Id);
|
||||
return false;
|
||||
}
|
||||
|
||||
var signingString = BuildSigningString(context, signatureParts);
|
||||
var signature = signatureParts.GetValueOrDefault("signature");
|
||||
|
||||
if (string.IsNullOrEmpty(signingString) || string.IsNullOrEmpty(signature))
|
||||
{
|
||||
logger.LogWarning("Failed to build signing string or extract signature");
|
||||
return false;
|
||||
}
|
||||
|
||||
var isValid = keyService.Verify(actor.PublicKey, signingString, signature);
|
||||
|
||||
if (!isValid)
|
||||
logger.LogWarning("Signature verification failed for actor: {ActorUri}", actorUri);
|
||||
|
||||
return isValid;
|
||||
}
|
||||
|
||||
public async Task<Dictionary<string, string>> SignOutgoingRequest(
|
||||
HttpRequestMessage request,
|
||||
string actorUri
|
||||
)
|
||||
{
|
||||
var publisher = await GetPublisherByActorUri(actorUri);
|
||||
if (publisher == null)
|
||||
throw new InvalidOperationException("Publisher not found");
|
||||
|
||||
var keyPair = GetOrGenerateKeyPair(publisher);
|
||||
var keyId = $"{actorUri}#main-key";
|
||||
|
||||
var headersToSign = new[] { "(request-target)", "host", "date" };
|
||||
var signingString = BuildSigningStringForRequest(request, headersToSign);
|
||||
var signature = keyService.Sign(keyPair.privateKeyPem, signingString);
|
||||
|
||||
return new Dictionary<string, string>
|
||||
{
|
||||
["keyId"] = keyId,
|
||||
["algorithm"] = "rsa-sha256",
|
||||
["headers"] = string.Join(" ", headersToSign),
|
||||
["signature"] = signature
|
||||
};
|
||||
}
|
||||
|
||||
private SnFediverseActor? GetActorByKeyId(string keyId)
|
||||
{
|
||||
var actorUri = keyId.Split('#')[0];
|
||||
return db.FediverseActors.FirstOrDefault(a => a.Uri == actorUri);
|
||||
}
|
||||
|
||||
private async Task<SnPublisher?> GetPublisherByActorUri(string actorUri)
|
||||
{
|
||||
var username = actorUri.Split('/')[^1];
|
||||
return await db.Publishers.FirstOrDefaultAsync(p => p.Name == username);
|
||||
}
|
||||
|
||||
private (string? privateKeyPem, string? publicKeyPem) GetOrGenerateKeyPair(SnPublisher publisher)
|
||||
{
|
||||
var privateKeyPem = GetPublisherKey(publisher, "private_key");
|
||||
var publicKeyPem = GetPublisherKey(publisher, "public_key");
|
||||
|
||||
if (string.IsNullOrEmpty(privateKeyPem) || string.IsNullOrEmpty(publicKeyPem))
|
||||
{
|
||||
var (newPrivate, newPublic) = keyService.GenerateKeyPair();
|
||||
SavePublisherKey(publisher, "private_key", newPrivate);
|
||||
SavePublisherKey(publisher, "public_key", newPublic);
|
||||
return (newPrivate, newPublic);
|
||||
}
|
||||
|
||||
return (privateKeyPem, publicKeyPem);
|
||||
}
|
||||
|
||||
private string? GetPublisherKey(SnPublisher publisher, string keyName)
|
||||
{
|
||||
if (publisher.Meta == null)
|
||||
return null;
|
||||
|
||||
var metadata = publisher.Meta as Dictionary<string, object>;
|
||||
return metadata?.GetValueOrDefault(keyName)?.ToString();
|
||||
}
|
||||
|
||||
private void SavePublisherKey(SnPublisher publisher, string keyName, string keyValue)
|
||||
{
|
||||
publisher.Meta ??= new Dictionary<string, object>();
|
||||
var metadata = publisher.Meta as Dictionary<string, object>;
|
||||
if (metadata != null)
|
||||
{
|
||||
metadata[keyName] = keyValue;
|
||||
}
|
||||
}
|
||||
|
||||
private Dictionary<string, string>? ParseSignatureHeader(string signatureHeader)
|
||||
{
|
||||
var parts = new Dictionary<string, string>();
|
||||
|
||||
foreach (var item in signatureHeader.Split(','))
|
||||
{
|
||||
var keyValue = item.Trim().Split('=', 2);
|
||||
if (keyValue.Length != 2)
|
||||
continue;
|
||||
|
||||
var key = keyValue[0];
|
||||
var value = keyValue[1].Trim('"');
|
||||
parts[key] = value;
|
||||
}
|
||||
|
||||
return parts;
|
||||
}
|
||||
|
||||
private string BuildSigningString(HttpContext context, Dictionary<string, string> signatureParts)
|
||||
{
|
||||
var headers = signatureParts.GetValueOrDefault("headers")?.Split(' ');
|
||||
if (headers == null || headers.Length == 0)
|
||||
return string.Empty;
|
||||
|
||||
var sb = new StringBuilder();
|
||||
|
||||
foreach (var header in headers)
|
||||
{
|
||||
if (sb.Length > 0)
|
||||
sb.AppendLine();
|
||||
|
||||
sb.Append(header.ToLower());
|
||||
sb.Append(": ");
|
||||
|
||||
if (header == "(request-target)")
|
||||
{
|
||||
var method = context.Request.Method.ToLower();
|
||||
var path = context.Request.Path.Value ?? "";
|
||||
sb.Append($"{method} {path}");
|
||||
}
|
||||
else
|
||||
{
|
||||
if (context.Request.Headers.TryGetValue(header, out var values))
|
||||
{
|
||||
sb.Append(values.ToString());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private string BuildSigningStringForRequest(HttpRequestMessage request, string[] headers)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
|
||||
foreach (var header in headers)
|
||||
{
|
||||
if (sb.Length > 0)
|
||||
sb.AppendLine();
|
||||
|
||||
sb.Append(header.ToLower());
|
||||
sb.Append(": ");
|
||||
|
||||
if (header == "(request-target)")
|
||||
{
|
||||
var method = request.Method.Method.ToLower();
|
||||
var path = request.RequestUri?.PathAndQuery ?? "/";
|
||||
sb.Append($"{method} {path}");
|
||||
}
|
||||
else if (header == "host")
|
||||
{
|
||||
sb.Append(request.RequestUri?.Host);
|
||||
}
|
||||
else if (header == "date")
|
||||
{
|
||||
if (request.Headers.Contains("Date"))
|
||||
{
|
||||
sb.Append(request.Headers.GetValues("Date").First());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
85
DysonNetwork.Sphere/ActivityPub/WebFingerController.cs
Normal file
85
DysonNetwork.Sphere/ActivityPub/WebFingerController.cs
Normal file
@@ -0,0 +1,85 @@
|
||||
using System.Net.Mime;
|
||||
using DysonNetwork.Shared.Models;
|
||||
using DysonNetwork.Sphere.ActivityPub;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NodaTime;
|
||||
|
||||
namespace DysonNetwork.Sphere.ActivityPub;
|
||||
|
||||
[ApiController]
|
||||
[Route(".well-known")]
|
||||
public class WebFingerController(
|
||||
AppDatabase db,
|
||||
IConfiguration configuration,
|
||||
ILogger<WebFingerController> logger
|
||||
) : ControllerBase
|
||||
{
|
||||
private string Domain => configuration["ActivityPub:Domain"] ?? "localhost";
|
||||
|
||||
[HttpGet("webfinger")]
|
||||
[Produces("application/jrd+json")]
|
||||
public async Task<ActionResult<WebFingerResponse>> GetWebFinger([FromQuery] string resource)
|
||||
{
|
||||
if (string.IsNullOrEmpty(resource))
|
||||
return BadRequest("Missing resource parameter");
|
||||
|
||||
if (!resource.StartsWith("acct:"))
|
||||
return BadRequest("Invalid resource format");
|
||||
|
||||
var account = resource[5..];
|
||||
var parts = account.Split('@');
|
||||
if (parts.Length != 2)
|
||||
return BadRequest("Invalid account format");
|
||||
|
||||
var username = parts[0];
|
||||
var domain = parts[1];
|
||||
|
||||
if (domain != Domain)
|
||||
return NotFound();
|
||||
|
||||
var publisher = await db.Publishers
|
||||
.Include(p => p.Members)
|
||||
.FirstOrDefaultAsync(p => p.Name == username);
|
||||
|
||||
if (publisher == null)
|
||||
return NotFound();
|
||||
|
||||
var actorUrl = $"https://{Domain}/activitypub/actors/{username}";
|
||||
|
||||
var response = new WebFingerResponse
|
||||
{
|
||||
Subject = resource,
|
||||
Links =
|
||||
[
|
||||
new WebFingerLink
|
||||
{
|
||||
Rel = "self",
|
||||
Type = "application/activity+json",
|
||||
Href = actorUrl
|
||||
},
|
||||
new WebFingerLink
|
||||
{
|
||||
Rel = "http://webfinger.net/rel/profile-page",
|
||||
Type = "text/html",
|
||||
Href = $"https://{Domain}/users/{username}"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
return Ok(response);
|
||||
}
|
||||
}
|
||||
|
||||
public class WebFingerResponse
|
||||
{
|
||||
public string Subject { get; set; } = null!;
|
||||
public List<WebFingerLink> Links { get; set; } = [];
|
||||
}
|
||||
|
||||
public class WebFingerLink
|
||||
{
|
||||
public string Rel { get; set; } = null!;
|
||||
public string Type { get; set; } = null!;
|
||||
public string Href { get; set; } = null!;
|
||||
}
|
||||
Reference in New Issue
Block a user