From 92f957a536f2414c281e4f58855b5ddc0a05a77f Mon Sep 17 00:00:00 2001 From: Laura Hausmann Date: Wed, 14 Aug 2024 02:10:53 +0200 Subject: [PATCH] [backend/federation] Use content negotiation for host-meta responses --- .../Schemas/HostMetaJsonResponse.cs | 27 ------------- .../Federation/Schemas/HostMetaXmlResponse.cs | 24 ----------- .../Federation/WellKnownController.cs | 40 +++---------------- .../Core/Extensions/MvcBuilderExtensions.cs | 36 ++++++++++++++++- .../Core/Federation/WebFinger/Types.cs | 36 +++++++++++++---- .../Federation/WebFinger/WebFingerService.cs | 9 ++--- Iceshrimp.Backend/Startup.cs | 3 +- 7 files changed, 76 insertions(+), 99 deletions(-) delete mode 100644 Iceshrimp.Backend/Controllers/Federation/Schemas/HostMetaJsonResponse.cs delete mode 100644 Iceshrimp.Backend/Controllers/Federation/Schemas/HostMetaXmlResponse.cs diff --git a/Iceshrimp.Backend/Controllers/Federation/Schemas/HostMetaJsonResponse.cs b/Iceshrimp.Backend/Controllers/Federation/Schemas/HostMetaJsonResponse.cs deleted file mode 100644 index d376fd76..00000000 --- a/Iceshrimp.Backend/Controllers/Federation/Schemas/HostMetaJsonResponse.cs +++ /dev/null @@ -1,27 +0,0 @@ -using J = System.Text.Json.Serialization.JsonPropertyNameAttribute; - -namespace Iceshrimp.Backend.Controllers.Federation.Schemas; - -public class HostMetaJsonResponse() -{ - public HostMetaJsonResponse(string webDomain) : this() - { - Links = [new HostMetaJsonResponseLink(webDomain)]; - } - - [J("links")] public List? Links { get; set; } -} - -public class HostMetaJsonResponseLink() -{ - public HostMetaJsonResponseLink(string webDomain) : this() - { - Rel = "lrdd"; - Type = "application/jrd+json"; - Template = $"https://{webDomain}/.well-known/webfinger?resource={{uri}}"; - } - - [J("rel")] public string? Rel { get; set; } - [J("type")] public string? Type { get; set; } - [J("template")] public string? Template { get; set; } -} \ No newline at end of file diff --git a/Iceshrimp.Backend/Controllers/Federation/Schemas/HostMetaXmlResponse.cs b/Iceshrimp.Backend/Controllers/Federation/Schemas/HostMetaXmlResponse.cs deleted file mode 100644 index 70b2d997..00000000 --- a/Iceshrimp.Backend/Controllers/Federation/Schemas/HostMetaXmlResponse.cs +++ /dev/null @@ -1,24 +0,0 @@ -using System.Diagnostics.CodeAnalysis; -using System.Xml.Serialization; - -namespace Iceshrimp.Backend.Controllers.Federation.Schemas; - -[XmlRoot("XRD", Namespace = "http://docs.oasis-open.org/ns/xri/xrd-1.0", IsNullable = false)] -public class HostMetaXmlResponse() -{ - [XmlElement("Link")] public required HostMetaXmlResponseLink Link; - - [SetsRequiredMembers] - public HostMetaXmlResponse(string webDomain) : this() => Link = new HostMetaXmlResponseLink(webDomain); -} - -public class HostMetaXmlResponseLink() -{ - [XmlAttribute("rel")] public string Rel = "lrdd"; - [XmlAttribute("template")] public required string Template; - [XmlAttribute("type")] public string Type = "application/jrd+json"; - - [SetsRequiredMembers] - public HostMetaXmlResponseLink(string webDomain) : this() => - Template = $"https://{webDomain}/.well-known/webfinger?resource={{uri}}"; -} \ No newline at end of file diff --git a/Iceshrimp.Backend/Controllers/Federation/WellKnownController.cs b/Iceshrimp.Backend/Controllers/Federation/WellKnownController.cs index 3cf4da17..44028b31 100644 --- a/Iceshrimp.Backend/Controllers/Federation/WellKnownController.cs +++ b/Iceshrimp.Backend/Controllers/Federation/WellKnownController.cs @@ -1,10 +1,6 @@ using System.Net; -using System.Net.Http.Headers; using System.Net.Mime; -using System.Text; -using System.Xml.Serialization; using Iceshrimp.Backend.Controllers.Federation.Attributes; -using Iceshrimp.Backend.Controllers.Federation.Schemas; using Iceshrimp.Backend.Controllers.Shared.Attributes; using Iceshrimp.Backend.Core.Configuration; using Iceshrimp.Backend.Core.Database; @@ -24,7 +20,7 @@ namespace Iceshrimp.Backend.Controllers.Federation; public class WellKnownController(IOptions config, DatabaseContext db) : ControllerBase { [HttpGet("webfinger")] - [Produces("application/jrd+json", "application/json", "application/xrd+xml", "application/xml")] + [Produces("application/jrd+json", "application/xrd+xml")] [ProducesResults(HttpStatusCode.OK)] [ProducesErrors(HttpStatusCode.NotFound)] public async Task WebFinger([FromQuery] string resource) @@ -83,10 +79,7 @@ public class WellKnownController(IOptions config, Databa Template = $"https://{config.Value.WebDomain}/authorize-follow?acct={{uri}}" } ], - Aliases = [ - user.GetPublicUrl(config.Value), - user.GetPublicUri(config.Value) - ] + Aliases = [user.GetPublicUrl(config.Value), user.GetPublicUri(config.Value)] }; } @@ -116,35 +109,14 @@ public class WellKnownController(IOptions config, Databa [HttpGet("host-meta")] [Produces("application/xrd+xml", "application/jrd+json")] [ProducesResults(HttpStatusCode.OK)] - public ActionResult HostMeta() + public HostMetaResponse HostMeta() { - var accept = Request.Headers.Accept.OfType() - .SelectMany(p => p.Split(",")) - .Select(MediaTypeWithQualityHeaderValue.Parse) - .Select(p => p.MediaType) - .ToList(); - - if (accept.Contains("application/jrd+json") || accept.Contains("application/json")) - return Ok(HostMetaJson()); - - var obj = new HostMetaXmlResponse(config.Value.WebDomain); - var serializer = new XmlSerializer(obj.GetType()); - var writer = new Utf8StringWriter(); - - serializer.Serialize(writer, obj); - return Content(writer.ToString(), "application/xrd+xml"); + if (Request.Headers.Accept is []) Request.Headers.Accept = "application/xrd+xml"; + return new HostMetaResponse(config.Value.WebDomain); } [HttpGet("host-meta.json")] [Produces("application/jrd+json")] [ProducesResults(HttpStatusCode.OK)] - public HostMetaJsonResponse HostMetaJson() - { - return new HostMetaJsonResponse(config.Value.WebDomain); - } - - private class Utf8StringWriter : StringWriter - { - public override Encoding Encoding => Encoding.UTF8; - } + public HostMetaResponse HostMetaJson() => new(config.Value.WebDomain); } \ No newline at end of file diff --git a/Iceshrimp.Backend/Core/Extensions/MvcBuilderExtensions.cs b/Iceshrimp.Backend/Core/Extensions/MvcBuilderExtensions.cs index 323f010f..e802167f 100644 --- a/Iceshrimp.Backend/Core/Extensions/MvcBuilderExtensions.cs +++ b/Iceshrimp.Backend/Core/Extensions/MvcBuilderExtensions.cs @@ -1,11 +1,11 @@ using System.Buffers; using System.Net; using System.Text.Encodings.Web; -using System.Xml; using Iceshrimp.Backend.Controllers.Shared.Attributes; using Iceshrimp.Backend.Core.Middleware; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Formatters; +using Microsoft.AspNetCore.Mvc.Infrastructure; using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.Extensions.ObjectPool; using Microsoft.Extensions.Options; @@ -15,6 +15,17 @@ namespace Iceshrimp.Backend.Core.Extensions; public static class MvcBuilderExtensions { + public static IMvcBuilder AddControllersWithOptions(this IServiceCollection services) + { + services.AddSingleton(); + + return services.AddControllers(opts => + { + opts.RespectBrowserAcceptHeader = true; + opts.ReturnHttpNotAcceptable = true; + }); + } + public static IMvcBuilder AddMultiFormatter(this IMvcBuilder builder) { builder.Services.AddOptions() @@ -90,4 +101,27 @@ public static class MvcBuilderExtensions return builder; } +} + +/// +/// Overrides the default OutputFormatterSelector, to make sure we return a body when content negotiation errors occur. +/// +public class AcceptHeaderOutputFormatterSelector( + IOptions options, + ILoggerFactory loggerFactory +) : OutputFormatterSelector +{ + private readonly DefaultOutputFormatterSelector _fallbackSelector = new(options, loggerFactory); + private readonly List _formatters = [..options.Value.OutputFormatters]; + + public override IOutputFormatter? SelectFormatter( + OutputFormatterCanWriteContext context, IList formatters, MediaTypeCollection mediaTypes + ) + { + var selectedFormatter = _fallbackSelector.SelectFormatter(context, formatters, mediaTypes); + if (selectedFormatter == null) + throw new GracefulException(HttpStatusCode.NotAcceptable, "The requested Content-Type is not acceptable."); + + return selectedFormatter; + } } \ No newline at end of file diff --git a/Iceshrimp.Backend/Core/Federation/WebFinger/Types.cs b/Iceshrimp.Backend/Core/Federation/WebFinger/Types.cs index 09a097b7..10dd3787 100644 --- a/Iceshrimp.Backend/Core/Federation/WebFinger/Types.cs +++ b/Iceshrimp.Backend/Core/Federation/WebFinger/Types.cs @@ -8,7 +8,7 @@ using JI = System.Text.Json.Serialization.JsonIgnoreAttribute; namespace Iceshrimp.Backend.Core.Federation.WebFinger; [SuppressMessage("ReSharper", "ClassNeverInstantiated.Global")] -public sealed class WebFingerLink +public class WebFingerLink { [XmlAttribute("rel")] [J("rel")] [JR] public required string Rel { get; set; } = null!; @@ -28,15 +28,10 @@ public sealed class WebFingerLink public string? Template { get; set; } } -[SuppressMessage("ReSharper", "CollectionNeverUpdated.Global")] -[SuppressMessage("ReSharper", "ClassNeverInstantiated.Global")] [XmlRoot("XRD", Namespace = "http://docs.oasis-open.org/ns/xri/xrd-1.0", IsNullable = false)] public sealed class WebFingerResponse { - [XmlElement("Link")] - [J("links")] - [JR] - public required List Links { get; set; } = null!; + [XmlElement("Link")] [J("links")] [JR] public required List Links { get; set; } = null!; [XmlElement("Subject")] [J("subject")] @@ -49,6 +44,33 @@ public sealed class WebFingerResponse public List? Aliases { get; set; } } +[XmlRoot("XRD", Namespace = "http://docs.oasis-open.org/ns/xri/xrd-1.0", IsNullable = false)] +public class HostMetaResponse() +{ + [SetsRequiredMembers] + public HostMetaResponse(string webDomain) : this() + { + Links = + [ + new HostMetaResponseLink(webDomain, "application/jrd+json"), + new HostMetaResponseLink(webDomain, "application/xrd+xml") + ]; + } + + [XmlElement("Link")] [J("links")] [JR] public required List Links { get; set; } +} + +public sealed class HostMetaResponseLink() : WebFingerLink +{ + [SetsRequiredMembers] + public HostMetaResponseLink(string webDomain, string type) : this() + { + Rel = "lrdd"; + Type = type; + Template = $"https://{webDomain}/.well-known/webfinger?resource={{uri}}"; + } +} + public sealed class NodeInfoIndexResponse { [J("links")] [JR] public List Links { get; set; } = null!; diff --git a/Iceshrimp.Backend/Core/Federation/WebFinger/WebFingerService.cs b/Iceshrimp.Backend/Core/Federation/WebFinger/WebFingerService.cs index 8aef7aa7..5ef8662a 100644 --- a/Iceshrimp.Backend/Core/Federation/WebFinger/WebFingerService.cs +++ b/Iceshrimp.Backend/Core/Federation/WebFinger/WebFingerService.cs @@ -3,7 +3,6 @@ using System.Net; using System.Text.Encodings.Web; using System.Xml.Linq; using System.Xml.Serialization; -using Iceshrimp.Backend.Controllers.Federation.Schemas; using Iceshrimp.Backend.Core.Configuration; using Iceshrimp.Backend.Core.Middleware; using Iceshrimp.Backend.Core.Services; @@ -140,9 +139,9 @@ public class WebFingerService( var hostMetaUrl = $"{proto}://{domain}/.well-known/host-meta.json"; using var res = await client.SendAsync(httpRqSvc.Get(hostMetaUrl, ["application/jrd+json"]), HttpCompletionOption.ResponseHeadersRead); - var deserialized = await res.Content.ReadFromJsonAsync(); + var deserialized = await res.Content.ReadFromJsonAsync(); - var result = deserialized?.Links?.FirstOrDefault(p => p is + var result = deserialized?.Links.FirstOrDefault(p => p is { Rel: "lrdd", //Type: "application/jrd+json", @@ -162,9 +161,9 @@ public class WebFingerService( var hostMetaUrl = $"{proto}://{domain}/.well-known/host-meta"; using var res = await client.SendAsync(httpRqSvc.Get(hostMetaUrl, ["application/jrd+json"]), HttpCompletionOption.ResponseHeadersRead); - var deserialized = await res.Content.ReadFromJsonAsync(); + var deserialized = await res.Content.ReadFromJsonAsync(); - var result = deserialized?.Links?.FirstOrDefault(p => p is + var result = deserialized?.Links.FirstOrDefault(p => p is { Rel: "lrdd", //Type: "application/jrd+json", diff --git a/Iceshrimp.Backend/Startup.cs b/Iceshrimp.Backend/Startup.cs index d8761db1..7a60bc36 100644 --- a/Iceshrimp.Backend/Startup.cs +++ b/Iceshrimp.Backend/Startup.cs @@ -12,7 +12,8 @@ builder.Configuration.AddCustomConfiguration(); await PluginLoader.LoadPlugins(); -builder.Services.AddControllers() +builder.Services + .AddControllersWithOptions() .AddNewtonsoftJson() //TODO: remove once dotNetRdf switches to System.Text.Json (or we switch to LinkedData.NET) .ConfigureNewtonsoftJson() .AddMultiFormatter()