updated to accept only .tar.zstd uploads

This commit is contained in:
2026-09-23 17:48:42 +02:00
parent cf337483d7
commit c6b27cd3c9
15 changed files with 556 additions and 50 deletions
@@ -0,0 +1,45 @@
using System.Net;
using ParadoxSaveParser.WebAPI.Database;
using ParadoxSaveParser.WebAPI.HttpHelpers;
namespace ParadoxSaveParser.WebAPI.Routes;
internal class GetSaveFileHandler : RouteHandlerBase
{
public GetSaveFileHandler(CancellationToken cancelAllToken)
: base(cancelAllToken)
{
}
public override async Task<HttpStatusCode> HandleRequest(
HttpListenerContext ctx, ContextLogger requestLogger)
{
var idOrError = RequestHelper.GetQueryValue(ctx, "id");
if (idOrError.HasError)
return await ReturnHelper.ResponseError(ctx, requestLogger, _cancelAllToken, idOrError.Error!);
string id = idOrError.Value!;
var meta = await Program.DB.GetMetadata(id);
if(meta is null)
return await ReturnHelper.ResponseError(ctx, requestLogger, _cancelAllToken,
new ErrorMessage(HttpStatusCode.NotFound,
$"Save with id {id} not found"));
// file is being uploaded (Initialized) or was deleted (Error)
if(meta.status is SaveFileProcessingStatus.Initialized or SaveFileProcessingStatus.Error)
return await ReturnHelper.ResponseError(ctx, requestLogger, _cancelAllToken,
new ErrorMessage(HttpStatusCode.BadRequest,
$"Save with id {id} has status {meta.status}"));
IOPath saveFilePath = meta.GetSaveFilePath();
if (!File.Exists(saveFilePath))
return await ReturnHelper.ResponseError(ctx, requestLogger, _cancelAllToken,
new ErrorMessage(HttpStatusCode.InternalServerError,
$"Save with id {id} not found")
);
await using var saveFile = File.OpenRead(saveFilePath);
return await ReturnHelper.ResponseStream(ctx, requestLogger, _cancelAllToken,
saveFile, contentType: "application/zstd");
}
}
@@ -1,4 +1,4 @@
using System.Linq;
using System.IO;
using System.Net;
using ParadoxSaveParser.WebAPI.BackgroundTasks;
using ParadoxSaveParser.WebAPI.Database;
@@ -9,24 +9,30 @@ namespace ParadoxSaveParser.WebAPI.Routes;
public class UploadSaveHandler : RouteHandlerBase
{
private const int MaxOriginalFileNameLength = 255;
private readonly BackgroundJobManager _bgJobManager;
private readonly Dictionary<Game, ISaveDataFilter> _saveFilters;
private readonly Config.SaveUploadConfig _uploadConfig;
public UploadSaveHandler(
CancellationToken cancelAllToken,
CancellationToken cancelAllToken,
BackgroundJobManager bgJobManager,
Dictionary<Game, ISaveDataFilter> saveFilters)
Dictionary<Game, ISaveDataFilter> saveFilters,
Config.SaveUploadConfig uploadConfig)
: base(cancelAllToken)
{
_bgJobManager = bgJobManager;
_saveFilters = saveFilters;
_uploadConfig = uploadConfig;
}
public override async Task<HttpStatusCode> HandleRequest(
HttpListenerContext ctx, ContextLogger requestLogger)
{
string? contentType = ctx.Request.Headers.GetValues("Content-Type")?.FirstOrDefault();
if (contentType != "application/octet-stream")
string? contentType = ctx.Request.ContentType;
string? mediaType = contentType?.Split(';')[0].Trim();
if (!string.Equals(mediaType, "application/zstd", StringComparison.OrdinalIgnoreCase))
return await ReturnHelper.ResponseError(ctx, requestLogger, _cancelAllToken,
new ErrorMessage(HttpStatusCode.BadRequest,
$"Invalid request Content-Type: '{contentType}'"));
@@ -34,18 +40,82 @@ public class UploadSaveHandler : RouteHandlerBase
var gameStrOrError = RequestHelper.GetQueryValue(ctx, "game");
if (gameStrOrError.HasError)
return await ReturnHelper.ResponseError(ctx, requestLogger, _cancelAllToken, gameStrOrError.Error!);
if (!Enum.TryParse(gameStrOrError.Value, ignoreCase: true, out Game game))
return await ReturnHelper.ResponseError(ctx, requestLogger, _cancelAllToken,
new ErrorMessage(HttpStatusCode.BadRequest,
$"Invalid requested game: '{gameStrOrError.Value}'"));
var meta = await Program.DB.CreateMetadata(game);
$"Invalid requested game: '{gameStrOrError.Value}'"));
if (!_saveFilters.ContainsKey(game) || !GameSaveEntryNames.Map.ContainsKey(game))
return await ReturnHelper.ResponseError(ctx, requestLogger, _cancelAllToken,
new ErrorMessage(HttpStatusCode.BadRequest,
$"Game {game} is not supported"));
var saveFilePath = meta.GetSaveFilePath();
await using (var saveFile = File.OpenWrite(saveFilePath))
var fileTypeStrOrError = RequestHelper.GetQueryValue(ctx, "originalFileType");
if (fileTypeStrOrError.HasError)
return await ReturnHelper.ResponseError(ctx, requestLogger, _cancelAllToken, fileTypeStrOrError.Error!);
OriginalFileType? originalFileType = fileTypeStrOrError.Value!.ToLowerInvariant() switch
{
await using (var remoteStream = ctx.Request.InputStream)
await remoteStream.CopyToAsync(saveFile, _cancelAllToken);
"text" => OriginalFileType.Text,
"zip" => OriginalFileType.Zip,
_ => null
};
if (originalFileType is null)
return await ReturnHelper.ResponseError(ctx, requestLogger, _cancelAllToken,
new ErrorMessage(HttpStatusCode.BadRequest,
$"Invalid originalFileType: '{fileTypeStrOrError.Value}'"));
var fileNameOrError = RequestHelper.GetQueryValue(ctx, "originalFileName");
if (fileNameOrError.HasError)
return await ReturnHelper.ResponseError(ctx, requestLogger, _cancelAllToken, fileNameOrError.Error!);
string originalFileName = fileNameOrError.Value!;
if (originalFileName.Length > MaxOriginalFileNameLength)
return await ReturnHelper.ResponseError(ctx, requestLogger, _cancelAllToken,
new ErrorMessage(HttpStatusCode.BadRequest,
$"originalFileName is longer than {MaxOriginalFileNameLength} characters"));
// Content-Length is unknown (-1) for chunked requests, so body size is checked during copying too
if (ctx.Request.ContentLength64 > _uploadConfig.MaxSizeCompressedBytes)
return await ResponseUploadTooLarge(ctx, requestLogger);
var meta = await Program.DB.CreateMetadata(game, originalFileType.Value, originalFileName);
try
{
bool fitsLimit;
await using (var saveFile = File.OpenWrite(meta.GetSaveFilePath()))
{
await using (var remoteStream = ctx.Request.InputStream)
fitsLimit = await CopyWithLimit(remoteStream, saveFile, _uploadConfig.MaxSizeCompressedBytes);
}
if (!fitsLimit)
{
await Program.DB.DeleteMetadata(meta, "upload is too large");
return await ResponseUploadTooLarge(ctx, requestLogger);
}
long gameDataSize;
try
{
gameDataSize = await SaveArchive.GetGameDataSize(meta.GetSaveFilePath(), game, _cancelAllToken);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
await Program.DB.DeleteMetadata(meta, "invalid archive");
return await ReturnHelper.ResponseError(ctx, requestLogger, _cancelAllToken,
new ErrorMessage(HttpStatusCode.BadRequest,
$"Invalid save archive: {ex.Message}"));
}
if (gameDataSize > _uploadConfig.MaxSizePlainTextBytes)
{
await Program.DB.DeleteMetadata(meta, "game data is too large");
return await ReturnHelper.ResponseError(ctx, requestLogger, _cancelAllToken,
new ErrorMessage(HttpStatusCode.RequestEntityTooLarge,
$"Uncompressed game data size ({gameDataSize} bytes) exceeds limit "
+ $"({_uploadConfig.MaxSizePlainTextBytes} bytes)"));
}
}
catch
{
await Program.DB.DeleteMetadata(meta, "upload failed");
throw;
}
await Program.DB.UpdateMetadataStatus(meta, SaveFileProcessingStatus.Uploaded);
@@ -53,4 +123,27 @@ public class UploadSaveHandler : RouteHandlerBase
dynamic responseData = new { meta.id };
return await ReturnHelper.ResponseJson(ctx, requestLogger, _cancelAllToken, responseData);
}
}
private Task<HttpStatusCode> ResponseUploadTooLarge(HttpListenerContext ctx, ContextLogger requestLogger)
{
return ReturnHelper.ResponseError(ctx, requestLogger, _cancelAllToken,
new ErrorMessage(HttpStatusCode.RequestEntityTooLarge,
$"Upload size exceeds limit ({_uploadConfig.MaxSizeCompressedBytes} bytes)"));
}
/// <returns>false if source is longer than maxBytes</returns>
private async Task<bool> CopyWithLimit(Stream source, Stream dest, long maxBytes)
{
byte[] buffer = new byte[81920];
long totalBytes = 0;
int readBytes;
while ((readBytes = await source.ReadAsync(buffer, _cancelAllToken)) > 0)
{
totalBytes += readBytes;
if (totalBytes > maxBytes)
return false;
await dest.WriteAsync(buffer.AsMemory(0, readBytes), _cancelAllToken);
}
return true;
}
}