Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions src/Exceptionless.Core/Services/EventPostBodyReadState.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
namespace Exceptionless.Core.Services;

public interface IEventPostBodyReadState
{
int? RejectedStatusCode { get; }
string? RejectionReason { get; }
}
20 changes: 20 additions & 0 deletions src/Exceptionless.Core/Services/EventPostEnqueueResult.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
namespace Exceptionless.Core.Services;

public sealed record EventPostEnqueueResult(string? QueueEntryId = null, int? RejectedStatusCode = null, string? RejectionReason = null)
{
public bool IsQueued => !String.IsNullOrEmpty(QueueEntryId);
public bool IsRejected => RejectedStatusCode.HasValue;

public static EventPostEnqueueResult Queued(string queueEntryId)
{
ArgumentException.ThrowIfNullOrEmpty(queueEntryId);
return new EventPostEnqueueResult(queueEntryId);
}

public static EventPostEnqueueResult Rejected(int statusCode, string? reason)
{
return new EventPostEnqueueResult(RejectedStatusCode: statusCode, RejectionReason: reason);
}

public static EventPostEnqueueResult Failed { get; } = new();
}
51 changes: 45 additions & 6 deletions src/Exceptionless.Core/Services/EventPostService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@ public EventPostService(IQueue<EventPost> queue, IFileStorage storage,
}

public async Task<string?> EnqueueAsync(EventPost data, Stream stream, CancellationToken cancellationToken = default)
{
var result = await SaveAndEnqueueAsync(data, stream, cancellationToken);
return result.QueueEntryId;
}

public async Task<EventPostEnqueueResult> SaveAndEnqueueAsync(EventPost data, Stream stream, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(stream);

Expand All @@ -38,24 +44,33 @@ public EventPostService(IQueue<EventPost> queue, IFileStorage storage,
var saveTask = data.ShouldArchive ? _storage.SaveObjectAsync(data.FilePath, (EventPostInfo)data, cancellationToken) : Task.FromResult(true);
var savePayloadTask = _storage.SaveFileAsync(Path.ChangeExtension(data.FilePath, ".payload"), stream, cancellationToken);

if (!await saveTask)
bool infoSaved = await saveTask;
bool payloadSaved = await savePayloadTask;

if (stream is IEventPostBodyReadState { RejectedStatusCode: { } statusCode } rejectedBody)
{
await DeleteSavedEventPostFilesAsync(data);
return EventPostEnqueueResult.Rejected(statusCode, rejectedBody.RejectionReason);
}

if (!infoSaved)
{
using (_logger.BeginScope(new ExceptionlessState().Organization(data.OrganizationId).Property(nameof(EventPostInfo), data)))
_logger.LogError("Unable to save event post info");

await savePayloadTask;
return null;
return EventPostEnqueueResult.Failed;
}

if (!await savePayloadTask)
if (!payloadSaved)
{
using (_logger.BeginScope(new ExceptionlessState().Organization(data.OrganizationId).Property(nameof(EventPostInfo), data)))
_logger.LogError("Unable to save event post payload");

return null;
return EventPostEnqueueResult.Failed;
}

return await _queue.EnqueueAsync(data);
string? queueEntryId = await _queue.EnqueueAsync(data);
return !String.IsNullOrEmpty(queueEntryId) ? EventPostEnqueueResult.Queued(queueEntryId) : EventPostEnqueueResult.Failed;
}

public async Task<byte[]?> GetEventPostPayloadAsync(string path)
Expand Down Expand Up @@ -109,4 +124,28 @@ private static string GetArchivePath(DateTime createdUtc, string projectId, stri
{
return Path.Combine("archive", createdUtc.ToString("yy"), createdUtc.ToString("MM"), createdUtc.ToString("dd"), createdUtc.ToString("HH"), createdUtc.ToString("mm"), projectId, fileName);
}

private async Task DeleteSavedEventPostFilesAsync(EventPost data)
{
if (String.IsNullOrEmpty(data.FilePath))
return;

try
{
var tasks = new List<Task<bool>>
{
_storage.DeleteFileAsync(Path.ChangeExtension(data.FilePath, ".payload"))
};

if (data.ShouldArchive)
tasks.Add(_storage.DeleteFileAsync(data.FilePath));

await Task.WhenAll(tasks);
}
catch (StorageException ex)
{
using (_logger.BeginScope(new ExceptionlessState().Organization(data.OrganizationId).Property(nameof(EventPostInfo), data)))
_logger.LogWarning(ex, "Unable to delete rejected event post payload");
}
}
}
19 changes: 17 additions & 2 deletions src/Exceptionless.Web/Controllers/EventController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1195,6 +1195,7 @@ public Task<IActionResult> LegacyPostAsync([FromHeader][UserAgent] string? userA
[RequestBodyContentAttribute]
[ConfigurationResponseFilter]
[ProducesResponseType(StatusCodes.Status202Accepted)]
[ProducesResponseType(StatusCodes.Status413RequestEntityTooLarge)]
public Task<IActionResult> PostV1Async(string? projectId = null, [FromHeader][UserAgent] string? userAgent = null)
{
return PostAsync(projectId, 1, userAgent);
Expand Down Expand Up @@ -1253,6 +1254,7 @@ public Task<IActionResult> PostV1Async(string? projectId = null, [FromHeader][Us
[RequestBodyContentAttribute]
[ConfigurationResponseFilter]
[ProducesResponseType(StatusCodes.Status202Accepted)]
[ProducesResponseType(StatusCodes.Status413RequestEntityTooLarge)]
public Task<IActionResult> PostV2Async([FromHeader][UserAgent] string? userAgent = null)
{
return PostAsync(null, 2, userAgent);
Expand Down Expand Up @@ -1312,6 +1314,7 @@ public Task<IActionResult> PostV2Async([FromHeader][UserAgent] string? userAgent
[RequestBodyContentAttribute]
[ConfigurationResponseFilter]
[ProducesResponseType(StatusCodes.Status202Accepted)]
[ProducesResponseType(StatusCodes.Status413RequestEntityTooLarge)]
public Task<IActionResult> PostByProjectV2Async(string? projectId = null, [FromHeader][UserAgent] string? userAgent = null)
{
return PostAsync(projectId, 2, userAgent);
Expand Down Expand Up @@ -1353,7 +1356,11 @@ private async Task<IActionResult> PostAsync(string? projectId = null, int apiVer
charSet = contentType.Charset.ToString();
}

await _eventPostService.EnqueueAsync(new EventPost(_appOptions.EnableArchive)
Stream requestBody = _appOptions.MaximumEventPostSize > 0
? new EventPostRequestBodyStream(Request.Body, _appOptions.MaximumEventPostSize)
: Request.Body;

var result = await _eventPostService.SaveAndEnqueueAsync(new EventPost(_appOptions.EnableArchive)
{
ApiVersion = apiVersion,
CharSet = charSet,
Expand All @@ -1363,7 +1370,15 @@ await _eventPostService.EnqueueAsync(new EventPost(_appOptions.EnableArchive)
OrganizationId = project.OrganizationId,
ProjectId = project.Id,
UserAgent = userAgent,
}, Request.Body);
}, requestBody, HttpContext.RequestAborted);

if (result.IsRejected)
{
if (result.RejectedStatusCode == StatusCodes.Status413RequestEntityTooLarge)
await _usageService.IncrementTooBigAsync(project.OrganizationId, project.Id);

return StatusCode(result.RejectedStatusCode.GetValueOrDefault(StatusCodes.Status400BadRequest));
}
}
catch (Exception ex)
{
Expand Down
3 changes: 2 additions & 1 deletion src/Exceptionless.Web/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using Exceptionless.Core.Configuration;
using Exceptionless.Core.Extensions;
using Exceptionless.Insulation.Configuration;
using Exceptionless.Web.Utility;
using OpenTelemetry;
using Serilog;
using Serilog.Events;
Expand Down Expand Up @@ -92,7 +93,7 @@ public static IHostBuilder CreateHostBuilder(IConfigurationRoot config, string e
c.AddServerHeader = false;

if (options.MaximumEventPostSize > 0)
c.Limits.MaxRequestBodySize = options.MaximumEventPostSize;
c.Limits.MaxRequestBodySize = options.MaximumEventPostSize + EventPostRequestBodyStream.KestrelBodyLimitSlopBytes;
})
.UseStartup<Startup>();
})
Expand Down
151 changes: 151 additions & 0 deletions src/Exceptionless.Web/Utility/EventPostRequestBodyStream.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
using Exceptionless.Core.Services;
using HttpBadHttpRequestException = Microsoft.AspNetCore.Http.BadHttpRequestException;

namespace Exceptionless.Web.Utility;

public sealed class EventPostRequestBodyStream : Stream, IEventPostBodyReadState
{
public const long KestrelBodyLimitSlopBytes = 4096;

private readonly Stream _inner;
private readonly long _maximumBytes;
private long _bytesRead;

public EventPostRequestBodyStream(Stream inner, long maximumBytes)
{
ArgumentNullException.ThrowIfNull(inner);
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maximumBytes);

_inner = inner;
_maximumBytes = maximumBytes;
}

public int? RejectedStatusCode { get; private set; }
public string? RejectionReason { get; private set; }

public override bool CanRead => _inner.CanRead;
public override bool CanSeek => false;
public override bool CanWrite => false;
public override long Length => throw new NotSupportedException();

public override long Position
{
get => throw new NotSupportedException();
set => throw new NotSupportedException();
}

public override void Flush()
{
_inner.Flush();
}

public override Task FlushAsync(CancellationToken cancellationToken)
{
return _inner.FlushAsync(cancellationToken);
}

public override int Read(byte[] buffer, int offset, int count)
{
ValidateBufferArguments(buffer, offset, count);

if (count == 0 || RejectedStatusCode.HasValue)
return 0;

int readLength = GetReadLength(count);
if (readLength == 0)
return 0;

try
{
int bytesRead = _inner.Read(buffer, offset, readLength);
return HandleReadResult(bytesRead);
}
catch (HttpBadHttpRequestException ex)
{
Reject(ex.StatusCode, ex.Message);
return 0;
}
}

public override async ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default)
{
if (buffer.Length == 0 || RejectedStatusCode.HasValue)
return 0;

int readLength = GetReadLength(buffer.Length);
if (readLength == 0)
return 0;

try
{
int bytesRead = await _inner.ReadAsync(buffer[..readLength], cancellationToken);
return HandleReadResult(bytesRead);
}
catch (HttpBadHttpRequestException ex)
{
Reject(ex.StatusCode, ex.Message);
return 0;
}
}

public override Task<int> ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
{
ValidateBufferArguments(buffer, offset, count);
return ReadAsync(buffer.AsMemory(offset, count), cancellationToken).AsTask();
}

public override long Seek(long offset, SeekOrigin origin)
{
throw new NotSupportedException();
}

public override void SetLength(long value)
{
throw new NotSupportedException();
}

public override void Write(byte[] buffer, int offset, int count)
{
throw new NotSupportedException();
}

private int GetReadLength(int requestedLength)
{
long remaining = _maximumBytes - _bytesRead;
if (remaining < 0)
{
Reject(StatusCodes.Status413RequestEntityTooLarge, "Request body too large.");
return 0;
}

if (remaining == 0)
return 1;

if (remaining >= requestedLength)
return requestedLength;

return (int)remaining + 1;
}

private int HandleReadResult(int bytesRead)
{
if (bytesRead == 0)
return 0;

long totalBytesRead = _bytesRead + bytesRead;
if (totalBytesRead > _maximumBytes)
{
Reject(StatusCodes.Status413RequestEntityTooLarge, "Request body too large.");
return 0;
}

_bytesRead = totalBytesRead;
return bytesRead;
}

private void Reject(int statusCode, string reason)
{
RejectedStatusCode ??= statusCode;
RejectionReason ??= reason;
}
}
Loading