using System.IO; using System.Net; using ParadoxSaveParser.WebAPI.BackgroundTasks; using ParadoxSaveParser.WebAPI.Database; using ParadoxSaveParser.WebAPI.HttpHelpers; using ParadoxSaveParser.WebAPI.SaveDataFilters; namespace ParadoxSaveParser.WebAPI.Routes; public class UploadSaveHandler : RouteHandlerBase { private const int MaxOriginalFileNameLength = 255; private readonly BackgroundJobManager _bgJobManager; private readonly Dictionary _saveFilters; private readonly Config.SaveUploadConfig _uploadConfig; public UploadSaveHandler( CancellationToken cancelAllToken, BackgroundJobManager bgJobManager, Dictionary saveFilters, Config.SaveUploadConfig uploadConfig) : base(cancelAllToken) { _bgJobManager = bgJobManager; _saveFilters = saveFilters; _uploadConfig = uploadConfig; } public override async Task HandleRequest( HttpListenerContext ctx, ContextLogger requestLogger) { 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}'")); 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}'")); 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 fileTypeStrOrError = RequestHelper.GetQueryValue(ctx, "originalFileType"); if (fileTypeStrOrError.HasError) return await ReturnHelper.ResponseError(ctx, requestLogger, _cancelAllToken, fileTypeStrOrError.Error!); OriginalFileType? originalFileType = fileTypeStrOrError.Value!.ToLowerInvariant() switch { "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); _bgJobManager.StartNewParsingOperation(meta, _saveFilters[meta.game], _cancelAllToken); dynamic responseData = new { meta.id }; return await ReturnHelper.ResponseJson(ctx, requestLogger, _cancelAllToken, responseData); } private Task ResponseUploadTooLarge(HttpListenerContext ctx, ContextLogger requestLogger) { return ReturnHelper.ResponseError(ctx, requestLogger, _cancelAllToken, new ErrorMessage(HttpStatusCode.RequestEntityTooLarge, $"Upload size exceeds limit ({_uploadConfig.MaxSizeCompressedBytes} bytes)")); } /// false if source is longer than maxBytes private async Task 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; } }