Iceshrimp.NET/Iceshrimp.Backend/Core/Middleware/ErrorHandlerMiddleware.cs
Laura Hausmann 705e061f74
[backend/asp] Refactor middleware stack
This commit splits the request pipeline conditionally instead of invoking every middleware in the stack.

It also simplifies middleware instantiation by using runtime discovery, allowing for Plugins to add Middleware.
2024-11-18 19:02:44 +01:00

301 lines
No EOL
10 KiB
C#

using System.Collections.Immutable;
using System.Diagnostics.CodeAnalysis;
using System.Net;
using System.Xml.Serialization;
using Iceshrimp.Backend.Controllers.Mastodon.Attributes;
using Iceshrimp.Backend.Controllers.Mastodon.Schemas;
using Iceshrimp.Backend.Core.Configuration;
using Iceshrimp.Backend.Core.Extensions;
using Iceshrimp.Backend.Core.Services;
using Iceshrimp.Backend.Pages.Shared;
using Iceshrimp.Shared.Schemas.Web;
using Microsoft.Extensions.Options;
namespace Iceshrimp.Backend.Core.Middleware;
public class ErrorHandlerMiddleware(
[SuppressMessage("ReSharper", "SuggestBaseTypeForParameterInConstructor")]
IOptionsMonitor<Config.SecuritySection> options,
ILoggerFactory loggerFactory,
RazorViewRenderService razor
) : IMiddlewareService
{
public static ServiceLifetime Lifetime => ServiceLifetime.Singleton;
private static readonly XmlSerializer XmlSerializer = new(typeof(ErrorResponse));
public async Task InvokeAsync(HttpContext ctx, RequestDelegate next)
{
try
{
ctx.Response.Headers.RequestId = ctx.TraceIdentifier;
await next(ctx);
}
catch (Exception e)
{
// Get the name of the class & function where the exception originated, falling back to this one
var type = e.TargetSite?.DeclaringType?.FullName ?? typeof(ErrorHandlerMiddleware).FullName!;
if (type.Contains('>'))
type = type[..(type.IndexOf('>') + 1)];
var logger = loggerFactory.CreateLogger(type);
var verbosity = options.CurrentValue.ExceptionVerbosity;
if (ctx.Response.HasStarted)
{
if (e is GracefulException earlyCe)
{
var level = earlyCe.SuppressLog ? LogLevel.Trace : LogLevel.Debug;
if (earlyCe.Details != null)
{
logger.Log(level, "Request was rejected with {statusCode} {error}: {message} - {details}",
(int)earlyCe.StatusCode, earlyCe.Error, earlyCe.Message, earlyCe.Details);
}
else
{
logger.Log(level, "Request was rejected with {statusCode} {error}: {message}",
(int)earlyCe.StatusCode, earlyCe.Error, earlyCe.Message);
}
}
else
{
logger.LogError("Request encountered an unexpected error: {exception}", e.ToString());
}
return;
}
var isMastodon = ctx.GetEndpoint()?.Metadata.GetMetadata<MastodonApiControllerAttribute>() != null;
if (e is GracefulException ce)
{
if (ce.StatusCode == HttpStatusCode.Accepted)
{
ctx.Response.StatusCode = (int)ce.StatusCode;
await ctx.Response.CompleteAsync();
return;
}
if (verbosity > ExceptionVerbosity.Basic && ce.OverrideBasic)
verbosity = ExceptionVerbosity.Basic;
ctx.Response.StatusCode = (int)ce.StatusCode;
if (isMastodon)
{
var error = new MastodonErrorResponse
{
Error = verbosity >= ExceptionVerbosity.Basic ? ce.Message : ce.StatusCode.ToString(),
Description = verbosity >= ExceptionVerbosity.Basic ? ce.Details : null
};
ctx.Response.ContentType = "application/json";
await ctx.Response.WriteAsJsonAsync(error);
}
else
{
var error = new ErrorResponse(e)
{
StatusCode = ctx.Response.StatusCode,
Error = verbosity >= ExceptionVerbosity.Basic ? ce.Error : ce.StatusCode.ToString(),
Message = verbosity >= ExceptionVerbosity.Basic ? ce.Message : null,
Details = verbosity >= ExceptionVerbosity.Full ? ce.Details : null,
Errors = verbosity >= ExceptionVerbosity.Full ? (ce as ValidationException)?.Errors : null,
Source = verbosity >= ExceptionVerbosity.Full ? type : null,
RequestId = ctx.TraceIdentifier
};
await WriteResponse(error);
}
var level = ce.SuppressLog ? LogLevel.Trace : LogLevel.Debug;
if (ce.Details != null)
logger.Log(level,
"Request was rejected by {source} with {statusCode} {error}: {message} - {details}",
type, (int)ce.StatusCode, ce.Error, ce.Message, ce.Details);
else
logger.Log(level, "Request was rejected by {source} with {statusCode} {error}: {message}",
type, (int)ce.StatusCode, ce.Error, ce.Message);
}
else
{
ctx.Response.StatusCode = 500;
var error = new ErrorResponse(e)
{
StatusCode = 500,
Error = "Internal Server Error",
Message = verbosity >= ExceptionVerbosity.Basic ? e.Message : null,
Source = verbosity >= ExceptionVerbosity.Full ? type : null,
RequestId = ctx.TraceIdentifier
};
await WriteResponse(error);
logger.LogError("Request encountered an unexpected error: {exception}", e);
}
async Task WriteResponse(ErrorResponse payload)
{
var accept = ctx.Request.Headers.Accept.NotNull().SelectMany(p => p.Split(',')).ToImmutableArray();
var resType = ResponseType.Json;
if (accept.Any(IsHtml))
resType = ResponseType.Html;
else if (accept.Any(IsXml) && accept.All(p => !IsJson(p)))
resType = ResponseType.Xml;
ctx.Response.ContentType = resType switch
{
ResponseType.Json => "application/json",
ResponseType.Xml => "application/xml",
ResponseType.Html => "text/html;charset=utf8",
_ => throw new ArgumentOutOfRangeException(nameof(resType))
};
switch (resType)
{
case ResponseType.Json:
await ctx.Response.WriteAsJsonAsync(payload);
break;
case ResponseType.Xml:
{
var stream = ctx.Response.BodyWriter.AsStream();
XmlSerializer.Serialize(stream, payload);
break;
}
case ResponseType.Html:
{
var model = new ErrorPageModel(payload);
ctx.Response.ContentType = "text/html; charset=utf8";
var stream = ctx.Response.BodyWriter.AsStream();
await razor.RenderToStreamAsync("Shared/ErrorPage.cshtml", model, stream);
return;
}
default:
throw new ArgumentOutOfRangeException(nameof(resType));
}
}
bool IsXml(string s) => s.Contains("/xml") || s.Contains("+xml");
bool IsJson(string s) => s.Contains("/json") || s.Contains("+json");
bool IsHtml(string s) => s.Contains("/html");
}
}
private enum ResponseType
{
Json,
Xml,
Html
}
}
public class GracefulException(
HttpStatusCode statusCode,
string error,
string message,
string? details = null,
bool suppressLog = false,
bool overrideBasic = false
) : Exception(message)
{
public readonly string? Details = details;
public readonly string Error = error;
public readonly bool OverrideBasic = overrideBasic;
public readonly HttpStatusCode StatusCode = statusCode;
public readonly bool SuppressLog = suppressLog;
public GracefulException(HttpStatusCode statusCode, string message, string? details = null) :
this(statusCode, statusCode.ToString(), message, details) { }
public GracefulException(string message, string? details = null) :
this(HttpStatusCode.InternalServerError, HttpStatusCode.InternalServerError.ToString(), message, details) { }
public static GracefulException UnprocessableEntity(string message, string? details = null) =>
new(HttpStatusCode.UnprocessableEntity, message, details);
public static GracefulException Forbidden(string message, string? details = null) =>
new(HttpStatusCode.Forbidden, message, details);
public static GracefulException Unauthorized(string message, string? details = null) =>
new(HttpStatusCode.Unauthorized, message, details);
public static GracefulException NotFound(string message, string? details = null) =>
new(HttpStatusCode.NotFound, message, details);
public static GracefulException BadRequest(string message, string? details = null) =>
new(HttpStatusCode.BadRequest, message, details);
public static GracefulException RequestTimeout(string message, string? details = null) =>
new(HttpStatusCode.RequestTimeout, message, details);
public static GracefulException Conflict(string message, string? details = null) =>
new(HttpStatusCode.Conflict, message, details);
public static GracefulException RequestEntityTooLarge(string message, string? details = null) =>
new(HttpStatusCode.RequestEntityTooLarge, message, details);
public static GracefulException RecordNotFound() => new(HttpStatusCode.NotFound, "Record not found");
public static GracefulException MisdirectedRequest() =>
new(HttpStatusCode.MisdirectedRequest, HttpStatusCode.MisdirectedRequest.ToString(),
"This server is not configured to respond to this request.", null, true, true);
/// <summary>
/// This is intended for cases where no error occured, but the request needs to be aborted early (e.g. WebFinger
/// returning 410 Gone)
/// </summary>
public static GracefulException Accepted(string message) =>
new(HttpStatusCode.Accepted, HttpStatusCode.Accepted.ToString(), message, suppressLog: true);
}
public class AuthFetchException(HttpStatusCode statusCode, string message, string? details = null)
: GracefulException(statusCode, message, details)
{
public static AuthFetchException NotFound(string message) =>
new(HttpStatusCode.NotFound, HttpStatusCode.NotFound.ToString(), message);
}
public class LocalFetchException(string uri)
: GracefulException(HttpStatusCode.UnprocessableEntity, "Refusing to fetch activity from local domain")
{
public string Uri => uri;
}
public class InstanceBlockedException(string uri, string? host = null)
: GracefulException(HttpStatusCode.UnprocessableEntity, "Instance is blocked")
{
public string? Host
{
get
{
if (host != null) return host;
return System.Uri.TryCreate(uri, UriKind.Absolute, out var res) ? res.Host : null;
}
}
public string Uri => uri;
}
public class PublicPreviewDisabledException()
: GracefulException(HttpStatusCode.Forbidden, "Public preview is disabled on this instance.",
"The instance administrator has intentionally disabled this feature for privacy reasons.");
public class ValidationException(
HttpStatusCode statusCode,
string error,
string message,
IDictionary<string, string[]> errors
) : GracefulException(statusCode, error, message)
{
public IDictionary<string, string[]> Errors => errors;
}
public enum ExceptionVerbosity
{
[SuppressMessage("ReSharper", "UnusedMember.Global")]
None = 0,
Basic = 1,
Full = 2,
Debug = 3,
}