Skip to content
Closed
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
244 changes: 170 additions & 74 deletions Intersect.Editor/Forms/FrmUploadToServer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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) =
Expand Down Expand Up @@ -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<Stream>();
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<dynamic>(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)
Expand Down
7 changes: 7 additions & 0 deletions Intersect.Server/Web/ApiService.AppSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
8 changes: 8 additions & 0 deletions Intersect.Server/Web/ApiService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -409,6 +409,14 @@ internal partial class ApiService : ApplicationService<ServerContext, IApiServic

builder.Services.AddResponseCaching();

// Configure form options to support large file uploads
builder.Services.Configure<FormOptions>(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(
Expand Down
Loading
Loading