301 lines
No EOL
10 KiB
C#
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 WriteResponseAsync(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 WriteResponseAsync(error);
|
|
logger.LogError("Request encountered an unexpected error: {exception}", e);
|
|
}
|
|
|
|
async Task WriteResponseAsync(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,
|
|
} |