diff --git a/Intersect.Editor/Forms/FrmUploadToServer.cs b/Intersect.Editor/Forms/FrmUploadToServer.cs index 976ddc1bf1..396a0d6daa 100644 --- a/Intersect.Editor/Forms/FrmUploadToServer.cs +++ b/Intersect.Editor/Forms/FrmUploadToServer.cs @@ -661,7 +661,6 @@ private async Task PerformUpload() var uploadType = rbEditorAssets.Checked ? "editor" : "client"; var isEditorUpload = rbEditorAssets.Checked; var serverUrl = txtServerUrl.Text.TrimEnd('/'); - var endpoint = $"{serverUrl}/api/v1/editor/updates/{uploadType}"; // Build exclusion lists var (excludeFiles, excludeExtensions, excludeDirectories, typeSpecificExcludeFiles, typeSpecificExcludeDirectories) = @@ -729,111 +728,208 @@ private async Task PerformUpload() httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _tokenResponse.AccessToken); - const int batchSize = 10; - const int maxRetries = 3; + // Use chunked upload for files larger than 10MB, batch upload for smaller files + const long chunkThreshold = 10_000_000; // 10MB + const int chunkSize = 10_000_000; // 10MB chunks - for (var i = 0; i < files.Length; i += batchSize) + foreach (var filePath in files) { - var batch = files.Skip(i).Take(batchSize).ToArray(); - var attempt = 0; - Exception? lastException = null; + var fileInfo = new FileInfo(filePath); + var relativePath = Path.GetRelativePath(_selectedDirectory!, filePath).Replace('\\', '/'); + var relativeDirectory = Path.GetDirectoryName(relativePath)?.Replace('\\', '/') ?? string.Empty; - while (attempt <= maxRetries) + try { - var streams = new List(); - try + if (fileInfo.Length >= chunkThreshold) { - using var content = new MultipartFormDataContent(); + // Use chunked upload for large files + await UploadFileChunked( + httpClient, + serverUrl, + filePath, + relativePath, + relativeDirectory, + uploadType, + chunkSize + ); + } + else + { + // Use simple upload for small files + await UploadFileSimple( + httpClient, + serverUrl, + filePath, + relativePath, + uploadType + ); + } - foreach (var filePath in batch) - { - var relativePath = Path - .GetRelativePath(_selectedDirectory!, filePath) - .Replace('\\', '/'); + uploadedFiles++; + var progress = (int)(uploadedFiles / (float)totalFiles * 100); + progressBar.Value = Math.Min(progress, 100); + lblStatus.Text = Strings.UploadToServer.FilesUploaded.ToString(uploadedFiles, totalFiles); + } + catch (Exception ex) + { + throw new Exception($"Failed to upload {relativePath}: {ex.Message}", ex); + } + } - var stream = File.OpenRead(filePath); - streams.Add(stream); - var fileContent = new StreamContent(stream); - fileContent.Headers.ContentType = - MediaTypeHeaderValue.Parse("application/octet-stream"); + progressBar.Value = 100; + lblStatus.Text = Strings.UploadToServer.Success; - content.Add(fileContent, "files", relativePath); - } + DarkMessageBox.ShowInformation( + Strings.UploadToServer.Success, + Strings.UploadToServer.Completed, + DarkDialogButton.Ok, + Icon + ); + } - var response = await httpClient.PostAsync(endpoint, content); + private async Task UploadFileChunked( + HttpClient httpClient, + string serverUrl, + string filePath, + string relativePath, + string relativeDirectory, + string uploadType, + int chunkSize + ) + { + var fileInfo = new FileInfo(filePath); + var fileName = Path.GetFileName(relativePath); - if (response.StatusCode == - System.Net.HttpStatusCode.Unauthorized) - { - // Clear the invalid token - _tokenResponse = null; - Preferences.SavePreference(nameof(TokenResponse), string.Empty); - Invoke(new Action(UpdateAuthenticationStatus)); + // Initialize upload session + var initRequest = new + { + FileName = fileName, + RelativePath = string.IsNullOrWhiteSpace(relativeDirectory) ? null : relativeDirectory, + UploadType = uploadType, + TotalSize = fileInfo.Length, + ChunkSize = chunkSize + }; - throw new Exception( - "Authentication failed. Your session may have expired. Please login again." - ); - } + var initJson = JsonConvert.SerializeObject(initRequest); + var initContent = new StringContent(initJson, Encoding.UTF8, "application/json"); + var initResponse = await httpClient.PostAsync($"{serverUrl}/api/v1/editor/chunked-upload/init", initContent); - if (!response.IsSuccessStatusCode) - { - var error = await response.Content.ReadAsStringAsync(); - throw new Exception( - $"Upload failed ({response.StatusCode}): {error}" - ); - } + if (!initResponse.IsSuccessStatusCode) + { + var error = await initResponse.Content.ReadAsStringAsync(); + throw new Exception($"Failed to initialize chunked upload: {error}"); + } - uploadedFiles += batch.Length; - var progress = (int)( - uploadedFiles / (float)totalFiles * 100 - ); + var initResult = JsonConvert.DeserializeObject(await initResponse.Content.ReadAsStringAsync()); + string sessionId = initResult.sessionId; + int totalChunks = initResult.totalChunks; - progressBar.Value = Math.Min(progress, 100); - lblStatus.Text = - Strings.UploadToServer.FilesUploaded - .ToString(uploadedFiles, totalFiles); + lblStatus.Text = $"Uploading {fileName} in {totalChunks} chunks..."; - break; - } - catch (Exception ex) + // Upload chunks + using var fileStream = File.OpenRead(filePath); + var buffer = new byte[chunkSize]; + + for (int chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) + { + var bytesRead = await fileStream.ReadAsync(buffer, 0, chunkSize); + var chunkData = new byte[bytesRead]; + Array.Copy(buffer, chunkData, bytesRead); + + // Upload chunk with retry + const int maxRetries = 3; + Exception? lastException = null; + + for (int attempt = 0; attempt <= maxRetries; attempt++) + { + try { - lastException = ex; - attempt++; + using var chunkContent = new MultipartFormDataContent(); + chunkContent.Add(new StringContent(sessionId), "sessionId"); + chunkContent.Add(new StringContent(chunkIndex.ToString()), "chunkIndex"); - if (attempt <= maxRetries) + var chunkFileContent = new ByteArrayContent(chunkData); + chunkFileContent.Headers.ContentType = MediaTypeHeaderValue.Parse("application/octet-stream"); + chunkContent.Add(chunkFileContent, "file", $"chunk_{chunkIndex}"); + + var chunkResponse = await httpClient.PostAsync( + $"{serverUrl}/api/v1/editor/chunked-upload/chunk", + chunkContent + ); + + if (!chunkResponse.IsSuccessStatusCode) { - var delay = TimeSpan.FromSeconds(Math.Pow(2, attempt)); - lblStatus.Text = - $"Retrying batch ({attempt}/{maxRetries})..."; - await Task.Delay(delay); + var error = await chunkResponse.Content.ReadAsStringAsync(); + throw new Exception($"Chunk {chunkIndex} upload failed: {error}"); } + + lblStatus.Text = $"Uploading {fileName}: {chunkIndex + 1}/{totalChunks} chunks"; + break; } - finally + catch (Exception ex) { - // Ensure all streams are disposed - foreach (var stream in streams) + lastException = ex; + if (attempt < maxRetries) { - stream?.Dispose(); + await Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, attempt + 1))); } } } - // If all retries failed, throw the last exception - if (lastException != null && attempt > maxRetries) + if (lastException != null) { throw lastException; } } - progressBar.Value = 100; - lblStatus.Text = Strings.UploadToServer.Success; - - DarkMessageBox.ShowInformation( - Strings.UploadToServer.Success, - Strings.UploadToServer.Completed, - DarkDialogButton.Ok, - Icon + // Finalize upload + var finalizeRequest = new { SessionId = sessionId }; + var finalizeJson = JsonConvert.SerializeObject(finalizeRequest); + var finalizeContent = new StringContent(finalizeJson, Encoding.UTF8, "application/json"); + var finalizeResponse = await httpClient.PostAsync( + $"{serverUrl}/api/v1/editor/chunked-upload/finalize", + finalizeContent ); + + if (!finalizeResponse.IsSuccessStatusCode) + { + var error = await finalizeResponse.Content.ReadAsStringAsync(); + throw new Exception($"Failed to finalize upload: {error}"); + } + } + + private async Task UploadFileSimple( + HttpClient httpClient, + string serverUrl, + string filePath, + string relativePath, + string uploadType + ) + { + var endpoint = $"{serverUrl}/api/v1/editor/updates/{uploadType}"; + + using var content = new MultipartFormDataContent(); + using var stream = File.OpenRead(filePath); + var fileContent = new StreamContent(stream); + fileContent.Headers.ContentType = MediaTypeHeaderValue.Parse("application/octet-stream"); + + content.Add(fileContent, "files", relativePath); + + var response = await httpClient.PostAsync(endpoint, content); + + if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized) + { + _tokenResponse = null; + Preferences.SavePreference(nameof(TokenResponse), string.Empty); + Invoke(new Action(UpdateAuthenticationStatus)); + throw new Exception("Authentication failed. Your session may have expired. Please login again."); + } + + if (!response.IsSuccessStatusCode) + { + var error = await response.Content.ReadAsStringAsync(); + throw new Exception($"Upload failed ({response.StatusCode}): {error}"); + } } private async void btnTestUrl_Click(object sender, EventArgs e) diff --git a/Intersect.Server/Web/ApiService.AppSettings.cs b/Intersect.Server/Web/ApiService.AppSettings.cs index 19dc4c94dc..c60e648698 100644 --- a/Intersect.Server/Web/ApiService.AppSettings.cs +++ b/Intersect.Server/Web/ApiService.AppSettings.cs @@ -356,6 +356,13 @@ PlatformID.Win32S or PlatformID.Win32Windows or PlatformID.Win32NT or PlatformID throw new ArgumentOutOfRangeException(); } + // Add Subject Alternative Name (SAN) extension to support modern browsers + var sanBuilder = new SubjectAlternativeNameBuilder(); + sanBuilder.AddDnsName("localhost"); + sanBuilder.AddIpAddress(System.Net.IPAddress.Loopback); + sanBuilder.AddIpAddress(System.Net.IPAddress.IPv6Loopback); + certificateRequest.CertificateExtensions.Add(sanBuilder.Build()); + var selfSignedCertificate = certificateRequest.CreateSelfSigned( DateTimeOffset.Now, DateTimeOffset.Now.AddDays(30) diff --git a/Intersect.Server/Web/ApiService.cs b/Intersect.Server/Web/ApiService.cs index 02bbb011b5..2d9c637c9c 100644 --- a/Intersect.Server/Web/ApiService.cs +++ b/Intersect.Server/Web/ApiService.cs @@ -409,6 +409,14 @@ internal partial class ApiService : ApplicationService(options => + { + options.MultipartBodyLengthLimit = 524_288_000; // 500 MB + options.ValueLengthLimit = int.MaxValue; + options.MultipartHeadersLengthLimit = int.MaxValue; + }); + builder.Services.AddMvc(o => o.CacheProfiles.Add(nameof(AvatarController), AvatarController.ResponseCacheProfile)) .WithRazorPagesRoot("/Web/Pages") .AddRazorPagesOptions( diff --git a/Intersect.Server/Web/Controllers/Api/ChunkedUploadController.cs b/Intersect.Server/Web/Controllers/Api/ChunkedUploadController.cs new file mode 100644 index 0000000000..391ef77f91 --- /dev/null +++ b/Intersect.Server/Web/Controllers/Api/ChunkedUploadController.cs @@ -0,0 +1,415 @@ +using System.Collections.Concurrent; +using System.Net; +using System.Security.Claims; +using Intersect.Server.Web.Http; +using Intersect.Server.Web.Types; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; + +namespace Intersect.Server.Web.Controllers.Api; + +/// +/// API endpoints for chunked file uploads from the editor to the server. +/// Supports large files (multi-GB) with resume capability. +/// +[Route("api/v1/editor/chunked-upload")] +[ApiController] +[Authorize(Policy = "Developer")] +public sealed class ChunkedUploadController : ControllerBase +{ + private readonly IHostEnvironment _hostEnvironment; + private readonly ILogger _logger; + private readonly IOptionsMonitor _updateServerOptionsMonitor; + + // In-memory storage for upload sessions + // In production, consider using distributed cache (Redis) for multiple server instances + private static readonly ConcurrentDictionary _uploadSessions = new(); + private static readonly object _sessionLock = new(); + + public ChunkedUploadController( + IHostEnvironment hostEnvironment, + ILoggerFactory loggerFactory, + IOptionsMonitor updateServerOptionsMonitor + ) + { + _hostEnvironment = hostEnvironment; + _logger = loggerFactory.CreateLogger(); + _updateServerOptionsMonitor = updateServerOptionsMonitor; + } + + private string AssetRootPath => Path.Combine( + _hostEnvironment.ContentRootPath, + _updateServerOptionsMonitor.CurrentValue.AssetRoot + ); + + /// + /// Initialize a chunked upload session for a file. + /// + [HttpPost("init")] + [ProducesResponseType(typeof(InitUploadResponse), (int)HttpStatusCode.OK, ContentTypes.Json)] + [ProducesResponseType(typeof(ErrorResponse), (int)HttpStatusCode.BadRequest, ContentTypes.Json)] + public IActionResult InitializeUpload([FromBody] InitUploadRequest request) + { + if (!_updateServerOptionsMonitor.CurrentValue.Enabled) + { + return NotFound(new ErrorResponse { Error = "Update server is not enabled" }); + } + + if (string.IsNullOrWhiteSpace(request.FileName)) + { + return BadRequest(new ErrorResponse { Error = "File name is required" }); + } + + if (request.TotalSize <= 0) + { + return BadRequest(new ErrorResponse { Error = "Total size must be greater than 0" }); + } + + if (request.ChunkSize <= 0 || request.ChunkSize > 50_000_000) // Max 50MB per chunk + { + return BadRequest(new ErrorResponse { Error = "Chunk size must be between 1 and 50MB" }); + } + + var uploadType = request.UploadType?.ToLowerInvariant(); + if (uploadType != "client" && uploadType != "editor") + { + return BadRequest(new ErrorResponse { Error = "Upload type must be 'client' or 'editor'" }); + } + + // Generate unique upload session ID + var sessionId = Guid.NewGuid().ToString(); + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); + + // Calculate total chunks + var totalChunks = (int)Math.Ceiling((double)request.TotalSize / request.ChunkSize); + + // Create temp directory for chunks + var tempDir = Path.Combine(Path.GetTempPath(), "intersect-uploads", sessionId); + Directory.CreateDirectory(tempDir); + + var session = new UploadSession + { + SessionId = sessionId, + UserId = userId ?? "unknown", + FileName = request.FileName, + RelativePath = request.RelativePath ?? string.Empty, + UploadType = uploadType, + TotalSize = request.TotalSize, + ChunkSize = request.ChunkSize, + TotalChunks = totalChunks, + TempDirectory = tempDir, + CreatedAt = DateTime.UtcNow, + LastActivityAt = DateTime.UtcNow + }; + + _uploadSessions[sessionId] = session; + + _logger.LogInformation( + "Initialized chunked upload session {SessionId} for {FileName} ({TotalSize:N0} bytes, {TotalChunks} chunks)", + sessionId, request.FileName, request.TotalSize, totalChunks + ); + + return Ok(new InitUploadResponse + { + SessionId = sessionId, + TotalChunks = totalChunks, + ChunkSize = request.ChunkSize + }); + } + + /// + /// Upload a single chunk of a file. + /// + [HttpPost("chunk")] + [RequestSizeLimit(52_428_800)] // 50 MB + [RequestFormLimits(MultipartBodyLengthLimit = 52_428_800)] + [ProducesResponseType(typeof(ChunkUploadResponse), (int)HttpStatusCode.OK, ContentTypes.Json)] + [ProducesResponseType(typeof(ErrorResponse), (int)HttpStatusCode.BadRequest, ContentTypes.Json)] + public async Task UploadChunk([FromForm] string sessionId, [FromForm] int chunkIndex) + { + if (!_uploadSessions.TryGetValue(sessionId, out var session)) + { + return NotFound(new ErrorResponse { Error = "Upload session not found or expired" }); + } + + if (chunkIndex < 0 || chunkIndex >= session.TotalChunks) + { + return BadRequest(new ErrorResponse { Error = $"Invalid chunk index: {chunkIndex}" }); + } + + var file = Request.Form.Files.FirstOrDefault(); + if (file == null || file.Length == 0) + { + return BadRequest(new ErrorResponse { Error = "No file data provided" }); + } + + // Verify chunk hasn't been uploaded already + if (session.UploadedChunks.Contains(chunkIndex)) + { + _logger.LogWarning("Chunk {ChunkIndex} already uploaded for session {SessionId}", chunkIndex, sessionId); + return Ok(new ChunkUploadResponse + { + ChunkIndex = chunkIndex, + UploadedChunks = session.UploadedChunks.Count, + TotalChunks = session.TotalChunks, + IsComplete = session.UploadedChunks.Count == session.TotalChunks + }); + } + + // Save chunk to temp directory + var chunkPath = Path.Combine(session.TempDirectory, $"chunk_{chunkIndex:D6}"); + try + { + using var fileStream = System.IO.File.Create(chunkPath); + await file.CopyToAsync(fileStream); + + lock (_sessionLock) + { + session.UploadedChunks.Add(chunkIndex); + session.LastActivityAt = DateTime.UtcNow; + } + + _logger.LogDebug( + "Uploaded chunk {ChunkIndex}/{TotalChunks} for session {SessionId}", + chunkIndex, session.TotalChunks, sessionId + ); + + return Ok(new ChunkUploadResponse + { + ChunkIndex = chunkIndex, + UploadedChunks = session.UploadedChunks.Count, + TotalChunks = session.TotalChunks, + IsComplete = session.UploadedChunks.Count == session.TotalChunks + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to save chunk {ChunkIndex} for session {SessionId}", chunkIndex, sessionId); + return StatusCode(500, new ErrorResponse { Error = $"Failed to save chunk: {ex.Message}" }); + } + } + + /// + /// Get the status of an upload session (for resume capability). + /// + [HttpGet("status/{sessionId}")] + [ProducesResponseType(typeof(UploadStatusResponse), (int)HttpStatusCode.OK, ContentTypes.Json)] + [ProducesResponseType(typeof(ErrorResponse), (int)HttpStatusCode.NotFound, ContentTypes.Json)] + public IActionResult GetUploadStatus(string sessionId) + { + if (!_uploadSessions.TryGetValue(sessionId, out var session)) + { + return NotFound(new ErrorResponse { Error = "Upload session not found or expired" }); + } + + return Ok(new UploadStatusResponse + { + SessionId = sessionId, + FileName = session.FileName, + UploadedChunks = session.UploadedChunks.OrderBy(c => c).ToList(), + TotalChunks = session.TotalChunks, + IsComplete = session.UploadedChunks.Count == session.TotalChunks + }); + } + + /// + /// Finalize an upload by assembling all chunks into the final file. + /// + [HttpPost("finalize")] + [ProducesResponseType(typeof(FinalizeUploadResponse), (int)HttpStatusCode.OK, ContentTypes.Json)] + [ProducesResponseType(typeof(ErrorResponse), (int)HttpStatusCode.BadRequest, ContentTypes.Json)] + public async Task FinalizeUpload([FromBody] FinalizeUploadRequest request) + { + if (!_uploadSessions.TryGetValue(request.SessionId, out var session)) + { + return NotFound(new ErrorResponse { Error = "Upload session not found or expired" }); + } + + // Verify all chunks are uploaded + if (session.UploadedChunks.Count != session.TotalChunks) + { + return BadRequest(new ErrorResponse + { + Error = $"Not all chunks uploaded: {session.UploadedChunks.Count}/{session.TotalChunks}" + }); + } + + try + { + // Construct destination path + var assetRootPath = AssetRootPath; + var relativePath = string.IsNullOrWhiteSpace(session.RelativePath) + ? session.UploadType + : Path.Combine(session.UploadType, session.RelativePath.Trim().Trim('/').Trim('\\')); + + var destinationFolder = Path.GetFullPath(Path.Combine(assetRootPath, relativePath)); + var relativeDestinationFolder = Path.GetRelativePath(assetRootPath, destinationFolder); + + // Security: Ensure uploads stay within the asset root + if (relativeDestinationFolder.StartsWith("..")) + { + _logger.LogWarning( + "{UserId} tried to upload to a folder outside of the sandbox: {DirectoryPath}", + session.UserId, destinationFolder + ); + return StatusCode( + (int)HttpStatusCode.Forbidden, + new ErrorResponse { Error = "Invalid destination path" } + ); + } + + // Create destination directory if needed + Directory.CreateDirectory(destinationFolder); + + // Assemble chunks into final file + var finalFilePath = Path.Combine(destinationFolder, session.FileName); + await AssembleChunks(session, finalFilePath); + + var fileInfo = new FileInfo(finalFilePath); + var relativeToAssetRoot = Path.GetRelativePath(assetRootPath, finalFilePath); + + _logger.LogInformation( + "Finalized chunked upload {SessionId}: {FileName} ({Size:N0} bytes) to {Path}", + session.SessionId, session.FileName, fileInfo.Length, relativeToAssetRoot + ); + + // Clean up temp files and session + CleanupSession(session); + + return Ok(new FinalizeUploadResponse + { + Success = true, + FileName = session.FileName, + Size = fileInfo.Length, + Path = relativeToAssetRoot + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to finalize upload for session {SessionId}", session.SessionId); + return StatusCode(500, new ErrorResponse { Error = $"Failed to finalize upload: {ex.Message}" }); + } + } + + /// + /// Cancel an upload session and clean up temporary files. + /// + [HttpDelete("{sessionId}")] + [ProducesResponseType((int)HttpStatusCode.OK)] + [ProducesResponseType(typeof(ErrorResponse), (int)HttpStatusCode.NotFound, ContentTypes.Json)] + public IActionResult CancelUpload(string sessionId) + { + if (!_uploadSessions.TryGetValue(sessionId, out var session)) + { + return NotFound(new ErrorResponse { Error = "Upload session not found" }); + } + + CleanupSession(session); + + _logger.LogInformation("Cancelled upload session {SessionId}", sessionId); + + return Ok(); + } + + private async Task AssembleChunks(UploadSession session, string destinationPath) + { + using var outputStream = System.IO.File.Create(destinationPath); + + // Assemble chunks in order + for (int i = 0; i < session.TotalChunks; i++) + { + var chunkPath = Path.Combine(session.TempDirectory, $"chunk_{i:D6}"); + if (!System.IO.File.Exists(chunkPath)) + { + throw new FileNotFoundException($"Chunk {i} not found", chunkPath); + } + + using var chunkStream = System.IO.File.OpenRead(chunkPath); + await chunkStream.CopyToAsync(outputStream); + } + } + + private void CleanupSession(UploadSession session) + { + try + { + if (Directory.Exists(session.TempDirectory)) + { + Directory.Delete(session.TempDirectory, recursive: true); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to clean up temp directory for session {SessionId}", session.SessionId); + } + + _uploadSessions.TryRemove(session.SessionId, out _); + } +} + +#region Models + +public class UploadSession +{ + public string SessionId { get; set; } = string.Empty; + public string UserId { get; set; } = string.Empty; + public string FileName { get; set; } = string.Empty; + public string RelativePath { get; set; } = string.Empty; + public string UploadType { get; set; } = string.Empty; // "client" or "editor" + public long TotalSize { get; set; } + public int ChunkSize { get; set; } + public int TotalChunks { get; set; } + public HashSet UploadedChunks { get; set; } = new(); + public string TempDirectory { get; set; } = string.Empty; + public DateTime CreatedAt { get; set; } + public DateTime LastActivityAt { get; set; } +} + +public class InitUploadRequest +{ + public string FileName { get; set; } = string.Empty; + public string? RelativePath { get; set; } + public string UploadType { get; set; } = string.Empty; // "client" or "editor" + public long TotalSize { get; set; } + public int ChunkSize { get; set; } = 10_000_000; // Default 10MB +} + +public class InitUploadResponse +{ + public string SessionId { get; set; } = string.Empty; + public int TotalChunks { get; set; } + public int ChunkSize { get; set; } +} + +public class ChunkUploadResponse +{ + public int ChunkIndex { get; set; } + public int UploadedChunks { get; set; } + public int TotalChunks { get; set; } + public bool IsComplete { get; set; } +} + +public class UploadStatusResponse +{ + public string SessionId { get; set; } = string.Empty; + public string FileName { get; set; } = string.Empty; + public List UploadedChunks { get; set; } = new(); + public int TotalChunks { get; set; } + public bool IsComplete { get; set; } +} + +public class FinalizeUploadRequest +{ + public string SessionId { get; set; } = string.Empty; +} + +public class FinalizeUploadResponse +{ + public bool Success { get; set; } + public string FileName { get; set; } = string.Empty; + public long Size { get; set; } + public string Path { get; set; } = string.Empty; +} + +#endregion diff --git a/Intersect.Server/Web/Controllers/Api/EditorUpdatesController.cs b/Intersect.Server/Web/Controllers/Api/EditorUpdatesController.cs index 56721fdb39..b8d6b31cad 100644 --- a/Intersect.Server/Web/Controllers/Api/EditorUpdatesController.cs +++ b/Intersect.Server/Web/Controllers/Api/EditorUpdatesController.cs @@ -54,6 +54,8 @@ IOptionsMonitor updateServerOptionsMonitor /// Optional subfolder within assets/client (e.g., "resources", "resources/images") /// Upload results for each file [HttpPost("client")] + [RequestSizeLimit(524_288_000)] // 500 MB + [RequestFormLimits(MultipartBodyLengthLimit = 524_288_000)] [ProducesResponseType(typeof(UploadResponse), (int)HttpStatusCode.OK, ContentTypes.Json)] [ProducesResponseType(typeof(UploadResponse), (int)HttpStatusCode.MultiStatus, ContentTypes.Json)] [ProducesResponseType(typeof(ErrorResponse), (int)HttpStatusCode.BadRequest, ContentTypes.Json)] @@ -73,6 +75,8 @@ public async Task UploadClientFiles( /// Optional subfolder within assets/editor /// Upload results for each file [HttpPost("editor")] + [RequestSizeLimit(524_288_000)] // 500 MB + [RequestFormLimits(MultipartBodyLengthLimit = 524_288_000)] [ProducesResponseType(typeof(UploadResponse), (int)HttpStatusCode.OK, ContentTypes.Json)] [ProducesResponseType(typeof(UploadResponse), (int)HttpStatusCode.MultiStatus, ContentTypes.Json)] [ProducesResponseType(typeof(ErrorResponse), (int)HttpStatusCode.BadRequest, ContentTypes.Json)] diff --git a/Intersect.Server/appsettings.Development.json b/Intersect.Server/appsettings.Development.json index 22d98b2ed1..cbd33c8450 100644 --- a/Intersect.Server/appsettings.Development.json +++ b/Intersect.Server/appsettings.Development.json @@ -15,6 +15,15 @@ }, "Url": "https://*:5443" } + }, + "Limits": { + "MaxRequestBodySize": 524288000, + "KeepAliveTimeout": "00:10:00", + "RequestHeadersTimeout": "00:10:00", + "MinRequestBodyDataRate": { + "BytesPerSecond": 100, + "GracePeriod": "00:10:00" + } } }, "Logging": { diff --git a/Intersect.Server/appsettings.Production.json b/Intersect.Server/appsettings.Production.json index 3dd6ec3d98..569d6652f1 100644 --- a/Intersect.Server/appsettings.Production.json +++ b/Intersect.Server/appsettings.Production.json @@ -18,7 +18,14 @@ }, "Limits": { "MaxConcurrentConnections": 100, - "MaxConcurrentUpgradedConnections": 100 + "MaxConcurrentUpgradedConnections": 100, + "MaxRequestBodySize": 524288000, + "KeepAliveTimeout": "00:10:00", + "RequestHeadersTimeout": "00:10:00", + "MinRequestBodyDataRate": { + "BytesPerSecond": 100, + "GracePeriod": "00:10:00" + } } }, "Logging": { diff --git a/Intersect.Server/appsettings.json b/Intersect.Server/appsettings.json index e573389532..810194f5a9 100644 --- a/Intersect.Server/appsettings.json +++ b/Intersect.Server/appsettings.json @@ -21,7 +21,16 @@ "Kestrel": { "AddServerHeader": false, "AllowResponseHeaderCompression": true, - "DisableStringReuse": false + "DisableStringReuse": false, + "Limits": { + "MaxRequestBodySize": 524288000, + "KeepAliveTimeout": "00:10:00", + "RequestHeadersTimeout": "00:10:00", + "MinRequestBodyDataRate": { + "BytesPerSecond": 100, + "GracePeriod": "00:10:00" + } + } }, "Logging": { "Common": {