From 068fe25d9d5925683faa6de55515f9105abcde4e Mon Sep 17 00:00:00 2001 From: Laura Hausmann Date: Tue, 23 Jan 2024 23:43:34 +0100 Subject: [PATCH] Implement nodeinfo --- .../Controllers/ActivityPubController.cs | 1 + .../Attributes/UseJsonAttribute.cs | 73 ++++++++++++++ .../Controllers/NodeInfoController.cs | 75 ++++++++++++++ .../Controllers/WellKnownController.cs | 20 ++++ .../Core/Federation/WebFinger/Types.cs | 98 ++++++++++++++++++- .../Core/Helpers/MvcBuilderExtensions.cs | 30 ++++++ Iceshrimp.Backend/Startup.cs | 4 +- Iceshrimp.NET.sln.DotSettings | 1 + 8 files changed, 296 insertions(+), 6 deletions(-) create mode 100644 Iceshrimp.Backend/Controllers/Attributes/UseJsonAttribute.cs create mode 100644 Iceshrimp.Backend/Controllers/NodeInfoController.cs create mode 100644 Iceshrimp.Backend/Core/Helpers/MvcBuilderExtensions.cs diff --git a/Iceshrimp.Backend/Controllers/ActivityPubController.cs b/Iceshrimp.Backend/Controllers/ActivityPubController.cs index 9eb681b1..f0ce2eba 100644 --- a/Iceshrimp.Backend/Controllers/ActivityPubController.cs +++ b/Iceshrimp.Backend/Controllers/ActivityPubController.cs @@ -10,6 +10,7 @@ using Microsoft.EntityFrameworkCore; namespace Iceshrimp.Backend.Controllers; [ApiController] +[UseNewtonsoftJson] [MediaTypeRouteFilter("application/activity+json", "application/ld+json")] [Produces("application/activity+json", "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"")] public class ActivityPubController(DatabaseContext db, APUserRenderer userRenderer) : Controller { diff --git a/Iceshrimp.Backend/Controllers/Attributes/UseJsonAttribute.cs b/Iceshrimp.Backend/Controllers/Attributes/UseJsonAttribute.cs new file mode 100644 index 00000000..a9015892 --- /dev/null +++ b/Iceshrimp.Backend/Controllers/Attributes/UseJsonAttribute.cs @@ -0,0 +1,73 @@ +using System.Text; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.AspNetCore.Mvc.Formatters; +using Microsoft.Extensions.Options; + +namespace Iceshrimp.Backend.Controllers.Attributes; + +public abstract class UseJsonAttribute : Attribute, IAsyncActionFilter { + public Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) { + return next(); + } +} + +public class UseNewtonsoftJsonAttribute : UseJsonAttribute; + +internal class JsonInputMultiFormatter : TextInputFormatter { + public JsonInputMultiFormatter() { + SupportedEncodings.Add(UTF8EncodingWithoutBOM); + SupportedEncodings.Add(UTF16EncodingLittleEndian); + SupportedMediaTypes.Add("text/json"); + SupportedMediaTypes.Add("application/json"); + SupportedMediaTypes.Add("application/*+json"); + } + + public override async Task ReadRequestBodyAsync( + InputFormatterContext context, Encoding encoding) { + var mvcOpt = context.HttpContext.RequestServices.GetRequiredService>().Value; + var formatters = mvcOpt.InputFormatters; + TextInputFormatter? formatter; + + var endpoint = context.HttpContext.GetEndpoint(); + if (endpoint?.Metadata.GetMetadata() != null) + // We can't use OfType because NewtonsoftJsonPatchInputFormatter exists + formatter = (NewtonsoftJsonInputFormatter?)formatters + .FirstOrDefault(f => typeof(NewtonsoftJsonInputFormatter) == f.GetType()); + else + // Default to System.Text.Json + formatter = formatters.OfType().FirstOrDefault(); + + if (formatter == null) throw new Exception("Failed to resolve formatter"); + + var result = await formatter.ReadRequestBodyAsync(context, encoding); + return result; + } +} + +internal class JsonOutputMultiFormatter : TextOutputFormatter { + public JsonOutputMultiFormatter() { + SupportedEncodings.Add(Encoding.UTF8); + SupportedEncodings.Add(Encoding.Unicode); + SupportedMediaTypes.Add("text/json"); + SupportedMediaTypes.Add("application/json"); + SupportedMediaTypes.Add("application/*+json"); + } + + public override async Task WriteResponseBodyAsync(OutputFormatterWriteContext context, Encoding selectedEncoding) { + var mvcOpt = context.HttpContext.RequestServices.GetRequiredService>().Value; + var formatters = mvcOpt.OutputFormatters; + TextOutputFormatter? formatter; + + var endpoint = context.HttpContext.GetEndpoint(); + if (endpoint?.Metadata.GetMetadata() != null) + formatter = formatters.OfType().FirstOrDefault(); + else + // Default to System.Text.Json + formatter = formatters.OfType().FirstOrDefault(); + + if (formatter == null) throw new Exception("Failed to resolve formatter"); + + await formatter.WriteResponseBodyAsync(context, selectedEncoding); + } +} \ No newline at end of file diff --git a/Iceshrimp.Backend/Controllers/NodeInfoController.cs b/Iceshrimp.Backend/Controllers/NodeInfoController.cs new file mode 100644 index 00000000..16c7e75e --- /dev/null +++ b/Iceshrimp.Backend/Controllers/NodeInfoController.cs @@ -0,0 +1,75 @@ +using Iceshrimp.Backend.Core.Configuration; +using Iceshrimp.Backend.Core.Federation.WebFinger; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; + +namespace Iceshrimp.Backend.Controllers; + +[ApiController] +[Route("/nodeinfo")] +public class NodeInfoController(IOptions config) : Controller { + [HttpGet("2.1")] + [HttpGet("2.0")] + [Produces("application/json")] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(WebFingerResponse))] + public IActionResult GetNodeInfo() { + var instance = config.Value; + var result = new NodeInfoResponse { + Version = instance.Version, + Software = new NodeInfoResponse.NodeInfoSoftware { + Version = instance.Version, + Name = "Iceshrimp.NET", + Homepage = new Uri("https://iceshrimp.dev/iceshrimp/iceshrimp-rewrite"), + Repository = Request.Path.Value?.EndsWith("2.1") ?? false + ? new Uri("https://iceshrimp.dev/iceshrimp/iceshrimp-rewrite") + : null + }, + Protocols = ["activitypub"], + Services = new NodeInfoResponse.NodeInfoServices { + Inbound = [], + Outbound = ["atom1.0", "rss2.0"] + }, + Usage = new NodeInfoResponse.NodeInfoUsage { + //FIXME Implement members + Users = new NodeInfoResponse.NodeInfoUsers { + Total = 0, + ActiveMonth = 0, + ActiveHalfYear = 0 + }, + LocalComments = 0, + LocalPosts = 0 + }, + Metadata = new NodeInfoResponse.NodeInfoMetadata { + //FIXME Implement members + NodeName = "todo", + NodeDescription = "todo", + Maintainer = new NodeInfoResponse.Maintainer { + Name = "todo", + Email = "todo" + }, + Languages = [], + TosUrl = "todo", + RepositoryUrl = new Uri("https://iceshrimp.dev/iceshrimp/iceshrimp-rewrite"), + FeedbackUrl = new Uri("https://iceshrimp.dev/iceshrimp/iceshrimp-rewrite/issues"), + ThemeColor = "#000000", + DisableRegistration = true, + DisableLocalTimeline = false, + DisableRecommendedTimeline = false, + DisableGlobalTimeline = false, + EmailRequiredForSignup = false, + PostEditing = false, + PostImports = false, + EnableHCaptcha = false, + EnableRecaptcha = false, + MaxNoteTextLength = 0, + MaxCaptionTextLength = 0, + EnableGithubIntegration = false, + EnableDiscordIntegration = false, + EnableEmail = false + }, + OpenRegistrations = false + }; + + return Ok(result); + } +} \ No newline at end of file diff --git a/Iceshrimp.Backend/Controllers/WellKnownController.cs b/Iceshrimp.Backend/Controllers/WellKnownController.cs index cbb12b01..df2b2546 100644 --- a/Iceshrimp.Backend/Controllers/WellKnownController.cs +++ b/Iceshrimp.Backend/Controllers/WellKnownController.cs @@ -63,6 +63,26 @@ public class WellKnownController(IOptions config, Databa return Ok(response); } + [HttpGet("nodeinfo")] + [Produces("application/json")] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(NodeInfoIndexResponse))] + public IActionResult NodeInfo() { + var response = new NodeInfoIndexResponse { + Links = [ + new WebFingerLink { + Rel = "http://nodeinfo.diaspora.software/ns/schema/2.1", + Href = $"https://{config.Value.WebDomain}/nodeinfo/2.1" + }, + new WebFingerLink { + Rel = "http://nodeinfo.diaspora.software/ns/schema/2.0", + Href = $"https://{config.Value.WebDomain}/nodeinfo/2.0" + } + ] + }; + + return Ok(response); + } + [HttpGet("host-meta")] [Produces("application/xrd+xml")] [ProducesResponseType(StatusCodes.Status200OK)] diff --git a/Iceshrimp.Backend/Core/Federation/WebFinger/Types.cs b/Iceshrimp.Backend/Core/Federation/WebFinger/Types.cs index 6cfb517b..7ac4fb3e 100644 --- a/Iceshrimp.Backend/Core/Federation/WebFinger/Types.cs +++ b/Iceshrimp.Backend/Core/Federation/WebFinger/Types.cs @@ -1,15 +1,26 @@ using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Serialization; using J = System.Text.Json.Serialization.JsonPropertyNameAttribute; using JR = System.Text.Json.Serialization.JsonRequiredAttribute; +using JI = System.Text.Json.Serialization.JsonIgnoreAttribute; namespace Iceshrimp.Backend.Core.Federation.WebFinger; [SuppressMessage("ReSharper", "ClassNeverInstantiated.Global")] public sealed class WebFingerLink { - [J("rel")] [JR] public string Rel { get; set; } = null!; - [J("type")] public string? Type { get; set; } - [J("href")] public string? Href { get; set; } - [J("template")] public string? Template { get; set; } + [J("rel")] [JR] public string Rel { get; set; } = null!; + + [J("type")] + [JI(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Type { get; set; } + + [J("href")] + [JI(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Href { get; set; } + + [J("template")] + [JI(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Template { get; set; } } [SuppressMessage("ReSharper", "CollectionNeverUpdated.Global")] @@ -17,5 +28,82 @@ public sealed class WebFingerLink { public sealed class WebFingerResponse { [J("links")] [JR] public List Links { get; set; } = null!; [J("subject")] [JR] public string Subject { get; set; } = null!; - [J("aliases")] public List Aliases { get; set; } = []; + + [J("aliases")] + [JI(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? Aliases { get; set; } +} + +public sealed class NodeInfoIndexResponse { + [J("links")] [JR] public List Links { get; set; } = null!; +} + +public class NodeInfoResponse { + [J("version")] public required string Version { get; set; } + [J("software")] public required NodeInfoSoftware Software { get; set; } + [J("protocols")] public required List Protocols { get; set; } + [J("services")] public required NodeInfoServices Services { get; set; } + [J("usage")] public required NodeInfoUsage Usage { get; set; } + [J("metadata")] public required NodeInfoMetadata Metadata { get; set; } + [J("openRegistrations")] public required bool OpenRegistrations { get; set; } + + public class NodeInfoMetadata { + [J("nodeName")] public required string NodeName { get; set; } + [J("nodeDescription")] public required string NodeDescription { get; set; } + [J("maintainer")] public required Maintainer Maintainer { get; set; } + [J("langs")] public required List Languages { get; set; } + [J("tosUrl")] public required object TosUrl { get; set; } + [J("repositoryUrl")] public required Uri RepositoryUrl { get; set; } + [J("feedbackUrl")] public required Uri FeedbackUrl { get; set; } + [J("themeColor")] public required string ThemeColor { get; set; } + [J("disableRegistration")] public required bool DisableRegistration { get; set; } + [J("disableLocalTimeline")] public required bool DisableLocalTimeline { get; set; } + [J("disableRecommendedTimeline")] public required bool DisableRecommendedTimeline { get; set; } + [J("disableGlobalTimeline")] public required bool DisableGlobalTimeline { get; set; } + [J("emailRequiredForSignup")] public required bool EmailRequiredForSignup { get; set; } + [J("postEditing")] public required bool PostEditing { get; set; } + [J("postImports")] public required bool PostImports { get; set; } + [J("enableHcaptcha")] public required bool EnableHCaptcha { get; set; } + [J("enableRecaptcha")] public required bool EnableRecaptcha { get; set; } + [J("maxNoteTextLength")] public required long MaxNoteTextLength { get; set; } + [J("maxCaptionTextLength")] public required long MaxCaptionTextLength { get; set; } + [J("enableGithubIntegration")] public required bool EnableGithubIntegration { get; set; } + [J("enableDiscordIntegration")] public required bool EnableDiscordIntegration { get; set; } + [J("enableEmail")] public required bool EnableEmail { get; set; } + } + + public class Maintainer { + [J("name")] public required string Name { get; set; } + [J("email")] public required string Email { get; set; } + } + + public class NodeInfoServices { + [J("inbound")] public required List Inbound { get; set; } + [J("outbound")] public required List Outbound { get; set; } + } + + public class NodeInfoSoftware { + [J("name")] public required string Name { get; set; } + [J("version")] public required string Version { get; set; } + [J("homepage")] public required Uri Homepage { get; set; } + + /// + /// This is only part of nodeinfo 2.1 + /// + [J("repository")] + [JI(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Uri? Repository { get; set; } + } + + public class NodeInfoUsage { + [J("users")] public required NodeInfoUsers Users { get; set; } + [J("localPosts")] public required long LocalPosts { get; set; } + [J("localComments")] public required long LocalComments { get; set; } + } + + public class NodeInfoUsers { + [J("total")] public required long Total { get; set; } + [J("activeHalfyear")] public required long ActiveHalfYear { get; set; } + [J("activeMonth")] public required long ActiveMonth { get; set; } + } } \ No newline at end of file diff --git a/Iceshrimp.Backend/Core/Helpers/MvcBuilderExtensions.cs b/Iceshrimp.Backend/Core/Helpers/MvcBuilderExtensions.cs new file mode 100644 index 00000000..3444fbbb --- /dev/null +++ b/Iceshrimp.Backend/Core/Helpers/MvcBuilderExtensions.cs @@ -0,0 +1,30 @@ +using System.Buffers; +using Iceshrimp.Backend.Controllers.Attributes; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Formatters; +using Microsoft.Extensions.ObjectPool; +using Microsoft.Extensions.Options; + +namespace Iceshrimp.Backend.Core.Helpers; + +public static class MvcBuilderExtensions { + public static IMvcBuilder AddMultiFormatter(this IMvcBuilder builder) { + builder.Services.AddOptions() + .PostConfigure, IOptions, ArrayPool, + ObjectPoolProvider, ILoggerFactory>((opts, jsonOpts, _, _, _, loggerFactory) => { + // We need to re-add these one since .AddNewtonsoftJson() removes them + if(!opts.InputFormatters.OfType().Any()){ + var systemInputLogger = loggerFactory.CreateLogger(); + opts.InputFormatters.Add(new SystemTextJsonInputFormatter(jsonOpts.Value, systemInputLogger)); + } + if(!opts.OutputFormatters.OfType().Any()){ + opts.OutputFormatters.Add(new SystemTextJsonOutputFormatter(jsonOpts.Value.JsonSerializerOptions)); + } + + opts.InputFormatters.Insert(0, new JsonInputMultiFormatter()); + opts.OutputFormatters.Insert(0, new JsonOutputMultiFormatter()); + }); + + return builder; + } +} \ No newline at end of file diff --git a/Iceshrimp.Backend/Startup.cs b/Iceshrimp.Backend/Startup.cs index 5315f9db..1772f806 100644 --- a/Iceshrimp.Backend/Startup.cs +++ b/Iceshrimp.Backend/Startup.cs @@ -15,7 +15,9 @@ builder.Configuration .AddIniFile(Environment.GetEnvironmentVariable("ICESHRIMP_CONFIG_OVERRIDES") ?? "configuration.overrides.ini", true, true); -builder.Services.AddControllers().AddNewtonsoftJson(); +builder.Services.AddControllers() + .AddNewtonsoftJson() //TODO: remove once dotNetRdf switches to System.Text.Json + .AddMultiFormatter(); builder.Services.AddApiVersioning(options => { options.DefaultApiVersion = new ApiVersion(1); options.ReportApiVersions = true; diff --git a/Iceshrimp.NET.sln.DotSettings b/Iceshrimp.NET.sln.DotSettings index 813b5c50..bc346e3f 100644 --- a/Iceshrimp.NET.sln.DotSettings +++ b/Iceshrimp.NET.sln.DotSettings @@ -36,4 +36,5 @@ True True True + True True \ No newline at end of file