using System.Text.Json.Serialization; using DysonNetwork.Shared.Models; 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 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> 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 assetsBaseUrl = configuration["ActivityPub:FileBaseUrl"] ?? $"https://{Domain}/files"; 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}", Icon = publisher.Picture != null ? new ActivityPubImage { Type = "Image", MediaType = publisher.Picture.MimeType, Url = $"{assetsBaseUrl}/{publisher.Picture.Id}" } : null, Image = publisher.Background != null ? new ActivityPubImage { Type = "Image", MediaType = publisher.Background.MimeType, Url = $"{assetsBaseUrl}/{publisher.Background.Id}" } : null, PublicKeys = [ new ActivityPubPublicKey { Id = $"{actorUrl}#main-key", Owner = actorUrl, PublicKeyPem = GetPublicKey(publisher) } ] }; 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 PostInbox(string username, [FromBody] Dictionary 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> 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(); 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); } [HttpGet("actors/{username}/followers")] [Produces("application/activity+json")] [ProducesResponseType(typeof(ActivityPubCollection), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ActivityPubCollectionPage), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] [SwaggerOperation( Summary = "Get ActivityPub followers", Description = "Returns the actor's followers collection with pagination support", OperationId = "GetActorFollowers" )] public async Task GetFollowers(string username, [FromQuery] int? page) { var publisher = await db.Publishers .FirstOrDefaultAsync(p => p.Name == username); if (publisher == null) return NotFound(); var actorUrl = $"https://{Domain}/activitypub/actors/{username}"; var followersUrl = $"{actorUrl}/followers"; var relationshipsQuery = db.FediverseRelationships .Include(r => r.Actor) .Where(r => r.LocalPublisherId == publisher.Id && r.IsFollowedBy); var totalItems = await relationshipsQuery.CountAsync(); if (page.HasValue) { const int pageSize = 40; var skip = (page.Value - 1) * pageSize; var actorUris = await relationshipsQuery .OrderByDescending(r => r.FollowedAt) .Skip(skip) .Take(pageSize) .Select(r => r.Actor.Uri) .ToListAsync(); var collectionPage = new ActivityPubCollectionPage { Context = ["https://www.w3.org/ns/activitystreams"], Id = $"{followersUrl}?page={page.Value}", Type = "OrderedCollectionPage", TotalItems = totalItems, PartOf = followersUrl, OrderedItems = actorUris }; return Ok(collectionPage); } else { var collection = new ActivityPubCollection { Context = ["https://www.w3.org/ns/activitystreams"], Id = followersUrl, Type = "OrderedCollection", TotalItems = totalItems, First = $"{followersUrl}?page=1" }; return Ok(collection); } } [HttpGet("actors/{username}/following")] [Produces("application/activity+json")] [ProducesResponseType(typeof(ActivityPubCollection), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ActivityPubCollectionPage), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] [SwaggerOperation( Summary = "Get ActivityPub following", Description = "Returns the actors that this actor follows with pagination support", OperationId = "GetActorFollowing" )] public async Task GetFollowing(string username, [FromQuery] int? page) { var publisher = await db.Publishers .FirstOrDefaultAsync(p => p.Name == username); if (publisher == null) return NotFound(); var actorUrl = $"https://{Domain}/activitypub/actors/{username}"; var followingUrl = $"{actorUrl}/following"; var relationshipsQuery = db.FediverseRelationships .Include(r => r.TargetActor) .Where(r => r.LocalPublisherId == publisher.Id && r.IsFollowing); var totalItems = await relationshipsQuery.CountAsync(); if (page.HasValue) { const int pageSize = 40; var skip = (page.Value - 1) * pageSize; var actorUris = await relationshipsQuery .OrderByDescending(r => r.FollowedAt) .Skip(skip) .Take(pageSize) .Select(r => r.TargetActor.Uri) .ToListAsync(); var collectionPage = new ActivityPubCollectionPage { Context = ["https://www.w3.org/ns/activitystreams"], Id = $"{followingUrl}?page={page.Value}", Type = "OrderedCollectionPage", TotalItems = totalItems, PartOf = followingUrl, OrderedItems = actorUris }; return Ok(collectionPage); } else { var collection = new ActivityPubCollection { Context = ["https://www.w3.org/ns/activitystreams"], Id = followingUrl, Type = "OrderedCollection", TotalItems = totalItems, First = $"{followingUrl}?page=1" }; return Ok(collection); } } private string GetPublicKey(SnPublisher publisher) { var publicKeyPem = GetPublisherKey(publisher, "public_key"); if (!string.IsNullOrEmpty(publicKeyPem)) return publicKeyPem; var (newPrivate, newPublic) = keyService.GenerateKeyPair(); SavePublisherKey(publisher, "private_key", newPrivate); SavePublisherKey(publisher, "public_key", newPublic); return newPublic; } private static string? GetPublisherKey(SnPublisher publisher, string keyName) { var metadata = publisher.Meta; return metadata?.GetValueOrDefault(keyName)?.ToString(); } private static void SavePublisherKey(SnPublisher publisher, string keyName, string keyValue) { publisher.Meta ??= new Dictionary(); publisher.Meta[keyName] = keyValue; } } public class ActivityPubActor { [JsonPropertyName("@context")] public List 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("icon")] public ActivityPubImage? Icon { get; set; } [JsonPropertyName("image")] public ActivityPubImage? Image { get; set; } [JsonPropertyName("publicKey")] public List? 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 ActivityPubImage { [JsonPropertyName("type")] public string? Type { get; set; } [JsonPropertyName("mediaType")] public string? MediaType { get; set; } [JsonPropertyName("url")] public string? Url { get; set; } } public class ActivityPubCollection { [JsonPropertyName("@context")] public List 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? Items { get; set; } } public class ActivityPubCollectionPage { [JsonPropertyName("@context")] public List 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("partOf")] public string? PartOf { get; set; } [JsonPropertyName("orderedItems")] public List? OrderedItems { get; set; } }