Post activitypub, and publisher update events

This commit is contained in:
2025-12-31 00:24:08 +08:00
parent 6aa6833163
commit 0519f2a2e6
5 changed files with 510 additions and 6 deletions

View File

@@ -96,15 +96,15 @@ public class ActivityPubActivityProcessor(
if (targetPublisher == null) if (targetPublisher == null)
{ {
logger.LogWarning("Target publisher not found: {Uri}, ExtractedUsername: {Username}", logger.LogWarning("Target publisher not found: {Uri}, ExtractedUsername: {Username}",
objectUri, targetUsername); objectUri, targetUsername);
return false; return false;
} }
var localActor = await deliveryService.GetOrCreateLocalActorAsync(targetPublisher); var localActor = await deliveryService.GetLocalActorAsync(targetPublisher.Id);
if (localActor == null) if (localActor == null)
{ {
logger.LogWarning("Target publisher has no actor..."); logger.LogWarning("Target publisher has no enabled fediverse actor");
return false; return false;
} }

View File

@@ -16,6 +16,7 @@ public class ActivityPubDeliveryService(
) )
{ {
private string Domain => configuration["ActivityPub:Domain"] ?? "localhost"; private string Domain => configuration["ActivityPub:Domain"] ?? "localhost";
private string AssetsBaseUrl => configuration["ActivityPub:FileBaseUrl"] ?? $"https://{Domain}/files";
private HttpClient HttpClient private HttpClient HttpClient
{ {
@@ -70,7 +71,7 @@ public class ActivityPubDeliveryService(
var actorUrl = $"https://{Domain}/activitypub/actors/{publisher.Name}"; var actorUrl = $"https://{Domain}/activitypub/actors/{publisher.Name}";
var targetActor = await GetOrFetchActorAsync(targetActorUri); var targetActor = await GetOrFetchActorAsync(targetActorUri);
var localActor = await GetOrCreateLocalActorAsync(publisher); var localActor = await GetLocalActorAsync(publisher.Id);
if (targetActor?.InboxUri == null || localActor == null) if (targetActor?.InboxUri == null || localActor == null)
{ {
@@ -168,6 +169,192 @@ public class ActivityPubDeliveryService(
return successCount > 0; return successCount > 0;
} }
public async Task<bool> SendUpdateActivityAsync(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/{Guid.NewGuid()}",
["type"] = "Update",
["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(),
["updated"] = post.EditedAt?.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();
var successCount = 0;
foreach (var follower in followers)
{
if (follower.InboxUri == null) continue;
var success = await SendActivityToInboxAsync(activity, follower.InboxUri, actorUrl);
if (success)
successCount++;
}
logger.LogInformation("Sent Update activity to {Count}/{Total} followers",
successCount, followers.Count);
return successCount > 0;
}
public async Task<bool> SendDeleteActivityAsync(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}/delete/{Guid.NewGuid()}",
["type"] = "Delete",
["actor"] = actorUrl,
["to"] = new[] { "https://www.w3.org/ns/activitystreams#Public" },
["cc"] = new[] { $"{actorUrl}/followers" },
["object"] = new Dictionary<string, object>
{
["id"] = postUrl,
["type"] = "Tombstone"
}
};
var followers = await GetRemoteFollowersAsync();
var successCount = 0;
foreach (var follower in followers)
{
if (follower.InboxUri == null) continue;
var success = await SendActivityToInboxAsync(activity, follower.InboxUri, actorUrl);
if (success)
successCount++;
}
logger.LogInformation("Sent Delete activity to {Count}/{Total} followers",
successCount, followers.Count);
return successCount > 0;
}
public async Task<bool> SendUpdateActorActivityAsync(SnFediverseActor actor)
{
var publisher = await db.Publishers
.Include(p => p.Picture)
.Include(p => p.Background)
.FirstOrDefaultAsync(p => p.Id == actor.PublisherId);
if (publisher == null)
return false;
var actorUrl = actor.Uri;
var actorObject = new Dictionary<string, object>
{
["id"] = actorUrl,
["type"] = actor.Type,
["name"] = publisher.Nick,
["preferredUsername"] = publisher.Name,
["summary"] = publisher.Bio ?? "",
["published"] = publisher.CreatedAt.ToDateTimeOffset(),
["updated"] = publisher.UpdatedAt.ToDateTimeOffset(),
["inbox"] = actor.InboxUri,
["outbox"] = actor.OutboxUri,
["followers"] = actor.FollowersUri,
["following"] = actor.FollowingUri,
["publicKey"] = new Dictionary<string, object>
{
["id"] = actor.PublicKeyId,
["owner"] = actorUrl,
["publicKeyPem"] = actor.PublicKey
}
};
if (publisher.Picture != null)
{
actorObject["icon"] = new Dictionary<string, object>
{
["type"] = "Image",
["mediaType"] = publisher.Picture.MimeType,
["url"] = $"{AssetsBaseUrl}/{publisher.Picture.Id}"
};
}
if (publisher.Background != null)
{
actorObject["image"] = new Dictionary<string, object>
{
["type"] = "Image",
["mediaType"] = publisher.Background.MimeType,
["url"] = $"{AssetsBaseUrl}/{publisher.Background.Id}"
};
}
var activity = new Dictionary<string, object>
{
["@context"] = new List<object>
{
"https://www.w3.org/ns/activitystreams",
"https://w3id.org/security/v1"
},
["id"] = $"{actorUrl}#update-{Guid.NewGuid()}",
["type"] = "Update",
["actor"] = actorUrl,
["published"] = DateTimeOffset.UtcNow,
["to"] = new[] { "https://www.w3.org/ns/activitystreams#Public" },
["cc"] = new[] { $"{actorUrl}/followers" },
["object"] = actorObject
};
var followers = await db.FediverseRelationships
.Include(r => r.TargetActor)
.Where(r => r.ActorId == actor.Id && r.IsFollowedBy)
.Select(r => r.TargetActor)
.ToListAsync();
var successCount = 0;
foreach (var follower in followers)
{
if (follower.InboxUri == null) continue;
var success = await SendActivityToInboxAsync(activity, follower.InboxUri, actorUrl);
if (success)
successCount++;
}
logger.LogInformation("Sent Update actor activity to {Count}/{Total} followers",
successCount, followers.Count);
return successCount > 0;
}
public async Task<bool> SendLikeActivityAsync( public async Task<bool> SendLikeActivityAsync(
Guid postId, Guid postId,
Guid accountId, Guid accountId,
@@ -318,6 +505,13 @@ public class ActivityPubDeliveryService(
.ToListAsync(); .ToListAsync();
} }
public async Task<SnFediverseActor?> GetLocalActorAsync(Guid publisherId)
{
return await db.FediverseActors
.Include(a => a.Instance)
.FirstOrDefaultAsync(a => a.PublisherId == publisherId);
}
public async Task<SnFediverseActor?> GetOrCreateLocalActorAsync(SnPublisher publisher) public async Task<SnFediverseActor?> GetOrCreateLocalActorAsync(SnPublisher publisher)
{ {
var actorUrl = $"https://{Domain}/activitypub/actors/{publisher.Name}"; var actorUrl = $"https://{Domain}/activitypub/actors/{publisher.Name}";

View File

@@ -6,6 +6,7 @@ using DysonNetwork.Shared.Registry;
using DysonNetwork.Sphere.WebReader; using DysonNetwork.Sphere.WebReader;
using DysonNetwork.Sphere.Localization; using DysonNetwork.Sphere.Localization;
using DysonNetwork.Sphere.Publisher; using DysonNetwork.Sphere.Publisher;
using DysonNetwork.Sphere.ActivityPub;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Localization; using Microsoft.Extensions.Localization;
using NodaTime; using NodaTime;
@@ -207,6 +208,25 @@ public partial class PostService(
// Process link preview in the background to avoid delaying post creation // Process link preview in the background to avoid delaying post creation
_ = Task.Run(async () => await CreateLinkPreviewAsync(post)); _ = Task.Run(async () => await CreateLinkPreviewAsync(post));
// Send ActivityPub Create activity in background for public posts
if (post.PublishedAt is not null && post.PublishedAt.Value.ToDateTimeUtc() <= DateTime.UtcNow &&
post.Visibility == Shared.Models.PostVisibility.Public)
{
_ = Task.Run(async () =>
{
try
{
using var scope = factory.CreateScope();
var deliveryService = scope.ServiceProvider.GetRequiredService<ActivityPubDeliveryService>();
await deliveryService.SendCreateActivityAsync(post);
}
catch (Exception err)
{
logger.LogError($"Error when sending ActivityPub Create activity: {err.Message}");
}
});
}
return post; return post;
} }
@@ -284,6 +304,24 @@ public partial class PostService(
// Process link preview in the background to avoid delaying post update // Process link preview in the background to avoid delaying post update
_ = Task.Run(async () => await CreateLinkPreviewAsync(post)); _ = Task.Run(async () => await CreateLinkPreviewAsync(post));
// Send ActivityPub Update activity in background for public posts
if (post.Visibility == Shared.Models.PostVisibility.Public)
{
_ = Task.Run(async () =>
{
try
{
using var scope = factory.CreateScope();
var deliveryService = scope.ServiceProvider.GetRequiredService<ActivityPubDeliveryService>();
await deliveryService.SendUpdateActivityAsync(post);
}
catch (Exception err)
{
logger.LogError($"Error when sending ActivityPub Update activity: {err.Message}");
}
});
}
return post; return post;
} }
@@ -414,6 +452,24 @@ public partial class PostService(
await transaction.RollbackAsync(); await transaction.RollbackAsync();
throw; throw;
} }
// Send ActivityPub Delete activity in background for public posts
if (post.Visibility == Shared.Models.PostVisibility.Public)
{
_ = Task.Run(async () =>
{
try
{
using var scope = factory.CreateScope();
var deliveryService = scope.ServiceProvider.GetRequiredService<ActivityPubDeliveryService>();
await deliveryService.SendDeleteActivityAsync(post);
}
catch (Exception err)
{
logger.LogError($"Error when sending ActivityPub Delete activity: {err.Message}");
}
});
}
} }
public async Task<SnPost> PinPostAsync(SnPost post, Account currentUser, Shared.Models.PostPinMode pinMode) public async Task<SnPost> PinPostAsync(SnPost post, Account currentUser, Shared.Models.PostPinMode pinMode)

View File

@@ -3,6 +3,7 @@ using DysonNetwork.Shared.Auth;
using DysonNetwork.Shared.Models; using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Proto; using DysonNetwork.Shared.Proto;
using DysonNetwork.Shared.Registry; using DysonNetwork.Shared.Registry;
using DysonNetwork.Sphere.ActivityPub;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@@ -20,7 +21,8 @@ public class PublisherController(
FileService.FileServiceClient files, FileService.FileServiceClient files,
FileReferenceService.FileReferenceServiceClient fileRefs, FileReferenceService.FileReferenceServiceClient fileRefs,
ActionLogService.ActionLogServiceClient als, ActionLogService.ActionLogServiceClient als,
RemoteRealmService remoteRealmService RemoteRealmService remoteRealmService,
IServiceScopeFactory factory
) : ControllerBase ) : ControllerBase
{ {
[HttpGet("{name}")] [HttpGet("{name}")]
@@ -668,6 +670,27 @@ public class PublisherController(
} }
); );
// Send ActivityPub Update activity if actor exists
var actor = await db.FediverseActors
.FirstOrDefaultAsync(a => a.PublisherId == publisher.Id);
if (actor != null)
{
_ = Task.Run(async () =>
{
try
{
using var scope = factory.CreateScope();
var deliveryService = scope.ServiceProvider.GetRequiredService<ActivityPubDeliveryService>();
await deliveryService.SendUpdateActorActivityAsync(actor);
}
catch (Exception ex)
{
Console.Error.WriteLine($"Error sending ActivityPub Update actor activity for publisher {publisher.Id}: {ex.Message}");
}
});
}
return Ok(publisher); return Ok(publisher);
} }
@@ -886,5 +909,87 @@ public class PublisherController(
await ps.SettlePublisherRewards(); await ps.SettlePublisherRewards();
return Ok(); return Ok();
} }
[HttpGet("{name}/fediverse")]
[Authorize]
public async Task<ActionResult<FediverseStatus>> GetFediverseStatus(string name)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
return Unauthorized();
var publisher = await db.Publishers.Where(p => p.Name == name).FirstOrDefaultAsync();
if (publisher is null)
return NotFound();
var status = await ps.GetFediverseStatusAsync(publisher.Id, Guid.Parse(currentUser.Id));
return Ok(status);
}
[HttpPost("{name}/fediverse")]
[Authorize]
public async Task<ActionResult<FediverseStatus>> EnableFediverse(string name)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
return Unauthorized();
var accountId = Guid.Parse(currentUser.Id);
var publisher = await db.Publishers.Where(p => p.Name == name).FirstOrDefaultAsync();
if (publisher is null)
return NotFound();
var member = await db
.PublisherMembers.Where(m => m.AccountId == accountId)
.Where(m => m.PublisherId == publisher.Id)
.FirstOrDefaultAsync();
if (member is null)
return StatusCode(403, "You are not even a member of targeted publisher.");
if (member.Role < Shared.Models.PublisherMemberRole.Manager)
return StatusCode(403, "You need at least be manager to enable fediverse for this publisher.");
try
{
var actor = await ps.EnableFediverseAsync(publisher.Id, accountId);
var status = await ps.GetFediverseStatusAsync(publisher.Id);
return Ok(status);
}
catch (InvalidOperationException ex)
{
return BadRequest(ex.Message);
}
}
[HttpDelete("{name}/fediverse")]
[Authorize]
public async Task<ActionResult> DisableFediverse(string name)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
return Unauthorized();
var accountId = Guid.Parse(currentUser.Id);
var publisher = await db.Publishers.Where(p => p.Name == name).FirstOrDefaultAsync();
if (publisher is null)
return NotFound();
var member = await db
.PublisherMembers.Where(m => m.AccountId == accountId)
.Where(m => m.PublisherId == publisher.Id)
.FirstOrDefaultAsync();
if (member is null)
return StatusCode(403, "You are not even a member of targeted publisher.");
if (member.Role < Shared.Models.PublisherMemberRole.Manager)
return StatusCode(403, "You need at least be manager to disable fediverse for this publisher.");
try
{
await ps.DisableFediverseAsync(publisher.Id, accountId);
return NoContent();
}
catch (UnauthorizedAccessException ex)
{
return StatusCode(403, ex.Message);
}
}
} }

View File

@@ -3,6 +3,7 @@ using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.Models; using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Proto; using DysonNetwork.Shared.Proto;
using DysonNetwork.Shared.Registry; using DysonNetwork.Shared.Registry;
using DysonNetwork.Sphere.ActivityPub;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using NodaTime; using NodaTime;
using NodaTime.Serialization.Protobuf; using NodaTime.Serialization.Protobuf;
@@ -11,13 +12,23 @@ using PublisherType = DysonNetwork.Shared.Models.PublisherType;
namespace DysonNetwork.Sphere.Publisher; namespace DysonNetwork.Sphere.Publisher;
public class FediverseStatus
{
public bool Enabled { get; set; }
public SnFediverseActor? Actor { get; set; }
public int FollowerCount { get; set; }
public string? ActorUri { get; set; }
}
public class PublisherService( public class PublisherService(
AppDatabase db, AppDatabase db,
FileReferenceService.FileReferenceServiceClient fileRefs, FileReferenceService.FileReferenceServiceClient fileRefs,
SocialCreditService.SocialCreditServiceClient socialCredits, SocialCreditService.SocialCreditServiceClient socialCredits,
ExperienceService.ExperienceServiceClient experiences, ExperienceService.ExperienceServiceClient experiences,
ICacheService cache, ICacheService cache,
RemoteAccountService remoteAccounts RemoteAccountService remoteAccounts,
ActivityPubKeyService keyService,
IConfiguration configuration
) )
{ {
public async Task<SnPublisher?> GetPublisherLoaded(Guid id) public async Task<SnPublisher?> GetPublisherLoaded(Guid id)
@@ -668,4 +679,142 @@ public class PublisherService(
} }
} }
} }
private string Domain => configuration["ActivityPub:Domain"] ?? "localhost";
public async Task<SnFediverseActor?> EnableFediverseAsync(Guid publisherId, Guid requesterAccountId)
{
var member = await db.PublisherMembers
.Where(m => m.PublisherId == publisherId && m.AccountId == requesterAccountId)
.FirstOrDefaultAsync();
if (member == null || member.Role < PublisherMemberRole.Manager)
throw new UnauthorizedAccessException("You need at least Manager role to enable fediverse for this publisher");
var existingActor = await db.FediverseActors
.FirstOrDefaultAsync(a => a.PublisherId == publisherId);
if (existingActor != null)
throw new InvalidOperationException("Fediverse actor already exists for this publisher");
var publisher = await db.Publishers
.Include(p => p.Picture)
.Include(p => p.Background)
.FirstOrDefaultAsync(p => p.Id == publisherId);
if (publisher == null)
throw new InvalidOperationException("Publisher not found");
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 (privateKey, publicKey) = keyService.GenerateKeyPair();
publisher.PrivateKeyPem = privateKey;
publisher.PublicKeyPem = publicKey;
var assetsBaseUrl = configuration["ActivityPub:FileBaseUrl"] ?? $"https://{Domain}/files";
var actorUrl = $"https://{Domain}/activitypub/actors/{publisher.Name}";
var actor = new SnFediverseActor
{
Uri = actorUrl,
Username = publisher.Name,
DisplayName = publisher.Nick,
Bio = publisher.Bio,
Type = "Person",
InboxUri = $"{actorUrl}/inbox",
OutboxUri = $"{actorUrl}/outbox",
FollowersUri = $"{actorUrl}/followers",
FollowingUri = $"{actorUrl}/following",
PublicKeyId = $"{actorUrl}#main-key",
PublicKey = publicKey,
AvatarUrl = publisher.Picture != null ? $"{assetsBaseUrl}/{publisher.Picture.Id}" : null,
HeaderUrl = publisher.Background != null ? $"{assetsBaseUrl}/{publisher.Background.Id}" : null,
InstanceId = instance.Id,
PublisherId = publisher.Id
};
db.FediverseActors.Add(actor);
db.Update(publisher);
await db.SaveChangesAsync();
return actor;
}
public async Task<bool> DisableFediverseAsync(Guid publisherId, Guid requesterAccountId)
{
var member = await db.PublisherMembers
.Where(m => m.PublisherId == publisherId && m.AccountId == requesterAccountId)
.FirstOrDefaultAsync();
if (member == null || member.Role < PublisherMemberRole.Manager)
throw new UnauthorizedAccessException("You need at least Manager role to disable fediverse for this publisher");
var actor = await db.FediverseActors
.FirstOrDefaultAsync(a => a.PublisherId == publisherId);
if (actor == null)
return true;
var publisher = await db.Publishers
.FirstOrDefaultAsync(p => p.Id == publisherId);
if (publisher != null)
{
publisher.PrivateKeyPem = null;
publisher.PublicKeyPem = null;
db.Update(publisher);
}
db.FediverseActors.Remove(actor);
await db.SaveChangesAsync();
return true;
}
public async Task<FediverseStatus?> GetFediverseStatusAsync(Guid publisherId, Guid? requesterAccountId = null)
{
var actor = await db.FediverseActors
.Include(a => a.Instance)
.FirstOrDefaultAsync(a => a.PublisherId == publisherId);
var followerCount = await db.FediverseRelationships
.Where(r => r.Actor.PublisherId == publisherId && r.IsFollowedBy)
.CountAsync();
var publisher = await db.Publishers
.FirstOrDefaultAsync(p => p.Id == publisherId);
if (publisher == null)
return null;
var assetsBaseUrl = configuration["ActivityPub:FileBaseUrl"] ?? $"https://{Domain}/files";
return new FediverseStatus
{
Enabled = actor != null,
Actor = actor,
FollowerCount = followerCount,
ActorUri = actor != null ? actor.Uri : null
};
}
public async Task<SnFediverseActor?> GetLocalActorAsync(Guid publisherId)
{
return await db.FediverseActors
.Include(a => a.Instance)
.FirstOrDefaultAsync(a => a.PublisherId == publisherId);
}
} }