diff --git a/DysonNetwork.Shared/Models/FediverseInstance.cs b/DysonNetwork.Shared/Models/FediverseInstance.cs index 173e321..0edb8d6 100644 --- a/DysonNetwork.Shared/Models/FediverseInstance.cs +++ b/DysonNetwork.Shared/Models/FediverseInstance.cs @@ -16,6 +16,11 @@ public class SnFediverseInstance : ModelBase [MaxLength(4096)] public string? Description { get; set; } [MaxLength(2048)] public string? Software { get; set; } [MaxLength(2048)] public string? Version { get; set; } + [MaxLength(2048)] public string? IconUrl { get; set; } + [MaxLength(2048)] public string? ThumbnailUrl { get; set; } + [MaxLength(512)] public string? ContactEmail { get; set; } + [MaxLength(256)] public string? ContactAccountUsername { get; set; } + public int? ActiveUsers { get; set; } [Column(TypeName = "jsonb")] public Dictionary? Metadata { get; set; } public bool IsBlocked { get; set; } = false; @@ -27,4 +32,5 @@ public class SnFediverseInstance : ModelBase public Instant? LastFetchedAt { get; set; } public Instant? LastActivityAt { get; set; } + public Instant? MetadataFetchedAt { get; set; } } diff --git a/DysonNetwork.Sphere/ActivityPub/ActivityPubActivityProcessor.cs b/DysonNetwork.Sphere/ActivityPub/ActivityPubActivityProcessor.cs index b24e832..ad0e216 100644 --- a/DysonNetwork.Sphere/ActivityPub/ActivityPubActivityProcessor.cs +++ b/DysonNetwork.Sphere/ActivityPub/ActivityPubActivityProcessor.cs @@ -9,6 +9,7 @@ public class ActivityPubActivityProcessor( AppDatabase db, ActivityPubSignatureService signatureService, ActivityPubDeliveryService deliveryService, + ActivityPubDiscoveryService discoveryService, ILogger logger ) { @@ -451,6 +452,7 @@ public class ActivityPubActivityProcessor( }; db.FediverseInstances.Add(instance); await db.SaveChangesAsync(); + await discoveryService.FetchInstanceMetadataAsync(instance); } return instance; diff --git a/DysonNetwork.Sphere/ActivityPub/ActivityPubDeliveryService.cs b/DysonNetwork.Sphere/ActivityPub/ActivityPubDeliveryService.cs index 2b10416..1ec5ffc 100644 --- a/DysonNetwork.Sphere/ActivityPub/ActivityPubDeliveryService.cs +++ b/DysonNetwork.Sphere/ActivityPub/ActivityPubDeliveryService.cs @@ -8,6 +8,7 @@ namespace DysonNetwork.Sphere.ActivityPub; public class ActivityPubDeliveryService( AppDatabase db, ActivityPubSignatureService signatureService, + ActivityPubDiscoveryService discoveryService, IHttpClientFactory httpClientFactory, IConfiguration configuration, ILogger logger @@ -310,6 +311,7 @@ public class ActivityPubDeliveryService( }; db.FediverseInstances.Add(instance); await db.SaveChangesAsync(); + await discoveryService.FetchInstanceMetadataAsync(instance); } actor = new SnFediverseActor diff --git a/DysonNetwork.Sphere/ActivityPub/ActivityPubDiscoveryService.cs b/DysonNetwork.Sphere/ActivityPub/ActivityPubDiscoveryService.cs index 76f250f..222933c 100644 --- a/DysonNetwork.Sphere/ActivityPub/ActivityPubDiscoveryService.cs +++ b/DysonNetwork.Sphere/ActivityPub/ActivityPubDiscoveryService.cs @@ -6,6 +6,54 @@ using System.Xml.Linq; namespace DysonNetwork.Sphere.ActivityPub; +public class MastodonInstanceV2Response +{ + public string Domain { get; set; } = null!; + public string Title { get; set; } = null!; + public string Version { get; set; } = null!; + public string? SourceUrl { get; set; } + public string? Description { get; set; } + public MastodonUsage? Usage { get; set; } + public MastodonThumbnail? Thumbnail { get; set; } + public List? Icon { get; set; } + public List? Languages { get; set; } + public MastodonContact? Contact { get; set; } + public Dictionary? Registrations { get; set; } + public Dictionary? Configuration { get; set; } +} + +public class MastodonUsage +{ + public MastodonUserUsage? Users { get; set; } +} + +public class MastodonUserUsage +{ + public int ActiveMonth { get; set; } +} + +public class MastodonThumbnail +{ + public string? Url { get; set; } +} + +public class MastodonIcon +{ + public string? Src { get; set; } + public string? Size { get; set; } +} + +public class MastodonContact +{ + public string? Email { get; set; } + public MastodonContactAccount? Account { get; set; } +} + +public class MastodonContactAccount +{ + public string? Username { get; set; } +} + public partial class ActivityPubDiscoveryService( AppDatabase db, IHttpClientFactory httpClientFactory, @@ -210,6 +258,105 @@ public partial class ActivityPubDiscoveryService( } } + public async Task FetchInstanceMetadataAsync(SnFediverseInstance instance) + { + if (instance.MetadataFetchedAt != null) + return; + + try + { + logger.LogInformation("Fetching instance metadata from Mastodon API: {Domain}", instance.Domain); + + var apiUrl = $"https://{instance.Domain}/api/v2/instance"; + var response = await HttpClient.GetAsync(apiUrl); + + if (!response.IsSuccessStatusCode) + { + logger.LogDebug("Mastodon API not available for {Domain} (Status: {StatusCode}), skipping metadata fetch", + instance.Domain, response.StatusCode); + return; + } + + var content = await response.Content.ReadAsStringAsync(); + var apiResponse = JsonSerializer.Deserialize(content); + + if (apiResponse == null) + { + logger.LogWarning("Failed to parse Mastodon API response for {Domain}", instance.Domain); + return; + } + + instance.Name = apiResponse.Title; + instance.Description = apiResponse.Description; + instance.Software = "Mastodon"; + instance.Version = apiResponse.Version; + instance.ThumbnailUrl = apiResponse.Thumbnail?.Url; + + if (apiResponse.Icon != null && apiResponse.Icon.Count > 0) + { + var largestIcon = apiResponse.Icon + .Where(i => i.Src != null) + .OrderByDescending(i => GetIconSizePixels(i.Size)) + .FirstOrDefault(); + instance.IconUrl = largestIcon?.Src; + } + + instance.ContactEmail = apiResponse.Contact?.Email; + instance.ContactAccountUsername = apiResponse.Contact?.Account?.Username; + instance.ActiveUsers = apiResponse.Usage?.Users?.ActiveMonth; + + var metadata = new Dictionary(); + + if (apiResponse.Languages != null && apiResponse.Languages.Count > 0) + metadata["languages"] = apiResponse.Languages; + + if (apiResponse.SourceUrl != null) + metadata["source_url"] = apiResponse.SourceUrl; + + if (apiResponse.Registrations != null) + metadata["registrations"] = apiResponse.Registrations; + + if (apiResponse.Configuration != null) + { + var filteredConfig = new Dictionary(); + if (apiResponse.Configuration.TryGetValue("media_attachments", out var mediaConfig)) + filteredConfig["media_attachments"] = mediaConfig; + if (apiResponse.Configuration.TryGetValue("polls", out var pollConfig)) + filteredConfig["polls"] = pollConfig; + if (apiResponse.Configuration.TryGetValue("translation", out var translationConfig)) + filteredConfig["translation"] = translationConfig; + metadata["configuration"] = filteredConfig; + } + + if (metadata.Count > 0) + instance.Metadata = metadata; + + instance.MetadataFetchedAt = NodaTime.SystemClock.Instance.GetCurrentInstant(); + await db.SaveChangesAsync(); + + logger.LogInformation("Successfully fetched instance metadata for {Domain}", instance.Domain); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to fetch instance metadata for {Domain}", instance.Domain); + } + } + + private static int GetIconSizePixels(string? size) + { + if (string.IsNullOrEmpty(size)) + return 0; + + var parts = size.Split('x'); + if (parts.Length != 2) + return 0; + + if (int.TryParse(parts[0], out var width) && int.TryParse(parts[1], out var height)) + return width * height; + + return 0; + } + private async Task StoreActorAsync( string actorUri, string username, @@ -255,8 +402,9 @@ public partial class ActivityPubDiscoveryService( await db.SaveChangesAsync(); logger.LogInformation("Successfully stored actor from Webfinger: {Username}@{Domain}", username, domain); - + await FetchActorDataAsync(actor); + await FetchInstanceMetadataAsync(instance); actor.Instance = instance; return actor; @@ -311,7 +459,6 @@ public partial class ActivityPubDiscoveryService( actor.IsLocked = actorData.GetValueOrDefault("manuallyApprovesFollowers")?.ToString() == "true"; actor.IsDiscoverable = actorData.GetValueOrDefault("discoverable")?.ToString() != "false"; - // Store additional fields in Metadata var excludedKeys = new HashSet { "id", "name", "summary", "preferredUsername", "inbox", "outbox", "followers", "following", "featured", diff --git a/DysonNetwork.Sphere/ActivityPub/ActivityPubFollowController.cs b/DysonNetwork.Sphere/ActivityPub/ActivityPubFollowController.cs index 8f15100..ec42558 100644 --- a/DysonNetwork.Sphere/ActivityPub/ActivityPubFollowController.cs +++ b/DysonNetwork.Sphere/ActivityPub/ActivityPubFollowController.cs @@ -304,6 +304,7 @@ public class ActivityPubFollowController( }; db.FediverseInstances.Add(instance); await db.SaveChangesAsync(); + await discSrv.FetchInstanceMetadataAsync(instance); } var actor = new SnFediverseActor diff --git a/DysonNetwork.Sphere/Migrations/AppDatabaseModelSnapshot.cs b/DysonNetwork.Sphere/Migrations/AppDatabaseModelSnapshot.cs index 3a94a11..c7ef6cd 100644 --- a/DysonNetwork.Sphere/Migrations/AppDatabaseModelSnapshot.cs +++ b/DysonNetwork.Sphere/Migrations/AppDatabaseModelSnapshot.cs @@ -671,11 +671,25 @@ namespace DysonNetwork.Sphere.Migrations .HasColumnType("uuid") .HasColumnName("id"); + b.Property("ActiveUsers") + .HasColumnType("integer") + .HasColumnName("active_users"); + b.Property("BlockReason") .HasMaxLength(2048) .HasColumnType("character varying(2048)") .HasColumnName("block_reason"); + b.Property("ContactAccountUsername") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("contact_account_username"); + + b.Property("ContactEmail") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("contact_email"); + b.Property("CreatedAt") .HasColumnType("timestamp with time zone") .HasColumnName("created_at"); @@ -695,6 +709,11 @@ namespace DysonNetwork.Sphere.Migrations .HasColumnType("character varying(256)") .HasColumnName("domain"); + b.Property("IconUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("icon_url"); + b.Property("IsBlocked") .HasColumnType("boolean") .HasColumnName("is_blocked"); @@ -715,6 +734,10 @@ namespace DysonNetwork.Sphere.Migrations .HasColumnType("jsonb") .HasColumnName("metadata"); + b.Property("MetadataFetchedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("metadata_fetched_at"); + b.Property("Name") .HasMaxLength(512) .HasColumnType("character varying(512)") @@ -725,6 +748,11 @@ namespace DysonNetwork.Sphere.Migrations .HasColumnType("character varying(2048)") .HasColumnName("software"); + b.Property("ThumbnailUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("thumbnail_url"); + b.Property("UpdatedAt") .HasColumnType("timestamp with time zone") .HasColumnName("updated_at");