Skip to content
Open
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
14 changes: 14 additions & 0 deletions UT4MasterServer.Models/DTO/Response/LogReportCreatedResponse.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
using System.Text.Json.Serialization;

namespace UT4MasterServer.Models.DTO.Responses;

public sealed class LogReportCreatedResponse
{
[JsonPropertyName("reportId")]
public string ReportID { get; set; }

public LogReportCreatedResponse(string reportID)
{
ReportID = reportID;
}
}
35 changes: 35 additions & 0 deletions UT4MasterServer.Models/DTO/Response/LogReportResponse.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
using System.Text.Json.Serialization;
using UT4MasterServer.Models.Database;

namespace UT4MasterServer.Models.DTO.Responses;

public sealed class LogReportResponse
{
[JsonPropertyName("reportId")]
public string ReportID { get; set; }

[JsonPropertyName("createdAt")]
public DateTime CreatedAt { get; set; }

[JsonPropertyName("player")]
public string? Player { get; set; }

[JsonPropertyName("note")]
public string? Note { get; set; }

[JsonPropertyName("length")]
public long Length { get; set; }

[JsonPropertyName("contentType")]
public string ContentType { get; set; }

public LogReportResponse(LogReport report)
{
ReportID = report.ReportID;
CreatedAt = report.CreatedAt;
Player = report.Player;
Note = report.Note;
Length = report.Length;
ContentType = report.ContentType;
}
}
46 changes: 46 additions & 0 deletions UT4MasterServer.Models/Database/LogReport.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;

namespace UT4MasterServer.Models.Database;

/// <summary>
/// Metadata about a single uploaded bug-report log file.
/// The log file bytes themselves are stored in a GridFS bucket and referenced by <see cref="FileID"/>.
/// </summary>
public sealed class LogReport
{
[BsonId, BsonIgnoreIfDefault, BsonElement("_id"), BsonRepresentation(BsonType.ObjectId)]
public string ID { get; set; } = default!;

/// <summary>
/// Short, human-friendly identifier of this report (8 uppercase Crockford base32 characters).
/// </summary>
public string ReportID { get; set; } = string.Empty;

/// <summary>
/// ID of the GridFS file containing the uploaded log bytes.
/// </summary>
public ObjectId FileID { get; set; }

[BsonIgnoreIfNull]
public string? Player { get; set; }

[BsonIgnoreIfNull]
public string? Note { get; set; }

/// <summary>
/// SHA-256 hash (lowercase hex) of the uploader's IP address. The raw IP is never stored.
/// </summary>
public string ClientIPHash { get; set; } = string.Empty;

public string UserAgent { get; set; } = string.Empty;

public DateTime CreatedAt { get; set; } = DateTime.UtcNow;

/// <summary>
/// Size of the uploaded log file in bytes.
/// </summary>
public long Length { get; set; }

public string ContentType { get; set; } = string.Empty;
}
24 changes: 24 additions & 0 deletions UT4MasterServer.Models/Settings/LogReportSettings.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
namespace UT4MasterServer.Models.Settings;

public sealed class LogReportSettings
{
/// <summary>
/// Name of the GridFS bucket in which uploaded log files are stored.
/// </summary>
public string BucketName { get; set; } = "bugreports";

/// <summary>
/// Maximum allowed size of a single uploaded log file in bytes.
/// </summary>
public int MaxFileSizeBytes { get; set; } = 25 * 1024 * 1024;

/// <summary>
/// Maximum number of uploads a single client IP may perform within <see cref="RateLimitWindowMinutes"/>.
/// </summary>
public int RateLimitMaxUploads { get; set; } = 10;

/// <summary>
/// Size of the sliding rate-limit window in minutes.
/// </summary>
public int RateLimitWindowMinutes { get; set; } = 60;
}
7 changes: 6 additions & 1 deletion UT4MasterServer.Services/Hosted/ApplicationStartupService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,16 @@ public sealed class ApplicationStartupService : IHostedService
private readonly CloudStorageService cloudStorageService;
private readonly ClientService clientService;
private readonly RatingsService ratingsService;
private readonly LogReportService logReportService;

public ApplicationStartupService(
ILogger<ApplicationStartupService> logger,
ILogger<StatisticsService> statsLogger,
IOptions<ApplicationSettings> settings,
ILogger<CloudStorageService> cloudStorageLogger,
ILogger<RatingsService> ratingsLogger)
ILogger<RatingsService> ratingsLogger,
ILogger<LogReportService> logReportLogger,
IOptions<LogReportSettings> logReportSettings)
{
this.logger = logger;
var db = new DatabaseContext(settings);
Expand All @@ -29,6 +32,7 @@ public ApplicationStartupService(
cloudStorageService = new CloudStorageService(db, cloudStorageLogger);
clientService = new ClientService(db);
ratingsService = new RatingsService(ratingsLogger, db);
logReportService = new LogReportService(logReportLogger, db, logReportSettings);
}

public async Task StartAsync(CancellationToken cancellationToken)
Expand All @@ -37,6 +41,7 @@ public async Task StartAsync(CancellationToken cancellationToken)
await accountService.CreateIndexesAsync();
await statisticsService.CreateIndexesAsync();
await ratingsService.CreateIndexesAsync();
await logReportService.CreateIndexesAsync();

logger.LogInformation("Initializing MongoDB CloudStorage.");
await cloudStorageService.EnsureSystemFilesExistAsync();
Expand Down
153 changes: 153 additions & 0 deletions UT4MasterServer.Services/Scoped/LogReportService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using MongoDB.Bson;
using MongoDB.Driver;
using MongoDB.Driver.GridFS;
using System.Security.Cryptography;
using UT4MasterServer.Models.Database;
using UT4MasterServer.Models.DTO.Responses;
using UT4MasterServer.Models.Settings;

namespace UT4MasterServer.Services.Scoped;

/// <summary>
/// Stores and retrieves bug-report log files. Log bytes are kept in a GridFS
/// bucket while a small metadata document per report is kept in the
/// "logreports" collection for cheap listing.
/// </summary>
public sealed class LogReportService
{
/// <summary>
/// Crockford base32 alphabet (no I, L, O, U) used for human-friendly report IDs.
/// </summary>
private const string ReportIDAlphabet = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
private const int ReportIDLength = 8;
private const int MaxReportIDGenerationAttempts = 10;

private readonly ILogger<LogReportService> logger;
private readonly IMongoCollection<LogReport> logReportCollection;
private readonly GridFSBucket bucket;

public LogReportService(
ILogger<LogReportService> logger,
DatabaseContext dbContext,
IOptions<LogReportSettings> settings)
{
this.logger = logger;
logReportCollection = dbContext.Database.GetCollection<LogReport>("logreports");
bucket = new GridFSBucket(dbContext.Database, new GridFSBucketOptions()
{
BucketName = settings.Value.BucketName
});
}

public async Task CreateIndexesAsync()
{
var indexes = new[]
{
new CreateIndexModel<LogReport>(
Builders<LogReport>.IndexKeys.Ascending(x => x.ReportID),
new CreateIndexOptions() { Unique = true }),
new CreateIndexModel<LogReport>(
Builders<LogReport>.IndexKeys.Descending(x => x.CreatedAt))
};
await logReportCollection.Indexes.CreateManyAsync(indexes);
}

public async Task<LogReport> CreateReportAsync(
Stream logStream, long length, string? player, string? note,
string clientIPHash, string userAgent, string contentType)
{
var reportID = await GenerateUniqueReportIDAsync();

ObjectId fileID = await bucket.UploadFromStreamAsync(reportID, logStream, new GridFSUploadOptions()
{
Metadata = new BsonDocument()
{
{ "ReportID", reportID },
{ "ContentType", contentType }
}
});

var report = new LogReport()
{
ReportID = reportID,
FileID = fileID,
Player = player,
Note = note,
ClientIPHash = clientIPHash,
UserAgent = userAgent,
CreatedAt = DateTime.UtcNow,
Length = length,
ContentType = contentType
};

await logReportCollection.InsertOneAsync(report);
Comment on lines +61 to +85

logger.LogInformation("Stored bug report {ReportID} ({Length} bytes).", reportID, length);

return report;
}

public async Task<PagedResponse<LogReport>> ListReportsAsync(int skip, int limit)
{
FilterDefinition<LogReport>? filter = Builders<LogReport>.Filter.Empty;

var count = await logReportCollection.CountDocumentsAsync(filter);
List<LogReport>? data = await logReportCollection
.Find(filter)
.SortByDescending(x => x.CreatedAt)
.Skip(skip)
.Limit(limit)
.ToListAsync();

return new PagedResponse<LogReport>()
{
Count = count,
Data = data
};
}

public async Task<LogReport?> GetReportAsync(string reportID)
{
IAsyncCursor<LogReport>? cursor = await logReportCollection.FindAsync(
Builders<LogReport>.Filter.Eq(x => x.ReportID, reportID));
return await cursor.SingleOrDefaultAsync();
}

public async Task<Stream> OpenLogStreamAsync(LogReport report)
{
return await bucket.OpenDownloadStreamAsync(report.FileID);
}

private async Task<string> GenerateUniqueReportIDAsync()
{
for (var attempt = 0; attempt < MaxReportIDGenerationAttempts; attempt++)
{
var reportID = GenerateReportID();

// the unique index on ReportID is the final guarantee, this check
// just avoids an insert failure in the common case
var existing = await logReportCollection.CountDocumentsAsync(
Builders<LogReport>.Filter.Eq(x => x.ReportID, reportID));
if (existing == 0)
{
return reportID;
}
}

throw new InvalidOperationException("Failed to generate a unique report ID");
}

private static string GenerateReportID()
{
// 256 % 32 == 0, so mapping bytes onto the alphabet introduces no modulo bias
var bytes = RandomNumberGenerator.GetBytes(ReportIDLength);
var chars = new char[ReportIDLength];
for (var i = 0; i < ReportIDLength; i++)
{
chars[i] = ReportIDAlphabet[bytes[i] % ReportIDAlphabet.Length];
}
return new string(chars);
}
}
60 changes: 60 additions & 0 deletions UT4MasterServer.Services/Singleton/LogReportRateLimitService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
namespace UT4MasterServer.Services.Singleton;

/// <summary>
/// Simple in-memory sliding-window rate limiter for anonymous log uploads.
/// Chosen over an external store on purpose: if master dies, the counters
/// disappear, which is acceptable for abuse protection of a low-volume
/// endpoint (same reasoning as <see cref="CodeService"/> keeping codes in memory).
/// </summary>
public sealed class LogReportRateLimitService
{
private const int CleanupThreshold = 1024;

private readonly Dictionary<string, List<DateTime>> uploadsPerClient = new();

/// <summary>
/// Records an upload attempt for <paramref name="clientKey"/> and returns
/// whether the attempt is allowed to proceed.
/// </summary>
public bool TryRecordUpload(string clientKey, int maxUploads, TimeSpan window)
{
var now = DateTime.UtcNow;

lock (uploadsPerClient) // Make sure counters are thread-safe
{
if (uploadsPerClient.Count >= CleanupThreshold)
{
RemoveStaleClients(now, window);
}
Comment on lines +11 to +28

if (!uploadsPerClient.TryGetValue(clientKey, out List<DateTime>? timestamps))
{
timestamps = new List<DateTime>();
uploadsPerClient.Add(clientKey, timestamps);
}

timestamps.RemoveAll(x => now - x > window);

if (timestamps.Count >= maxUploads)
{
return false;
}

timestamps.Add(now);
return true;
}
}

private void RemoveStaleClients(DateTime now, TimeSpan window)
{
var staleKeys = uploadsPerClient
.Where(x => !x.Value.Any(t => now - t <= window))
.Select(x => x.Key)
.ToList();

foreach (var key in staleKeys)
{
uploadsPerClient.Remove(key);
}
}
}
1 change: 1 addition & 0 deletions UT4MasterServer.Services/UT4MasterServer.Services.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="6.0.0" />
<PackageReference Include="Microsoft.Extensions.Options" Version="6.0.0" />
<PackageReference Include="MongoDB.Driver.GridFS" Version="2.25.0" />

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is not latest version

</ItemGroup>

<ItemGroup>
Expand Down
Loading
Loading