Skip to content

Commit 0647163

Browse files
committed
sftp + vfs
1 parent c4d9741 commit 0647163

File tree

85 files changed

+3414
-2880
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

85 files changed

+3414
-2880
lines changed

.github/copilot-instructions.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ ManagedCode.Storage is a universal storage abstraction library that provides a c
1212
- `ManagedCode.Storage.Aws`: AWS S3 implementation
1313
- `ManagedCode.Storage.Google`: Google Cloud Storage implementation
1414
- `ManagedCode.Storage.FileSystem`: Local file system implementation
15-
- `ManagedCode.Storage.Ftp`: FTP storage implementation
15+
- `ManagedCode.Storage.Sftp`: FTP storage implementation
1616
- `ManagedCode.Storage.Azure.DataLake`: Azure Data Lake implementation
1717
- **Tests/**: Unit and integration tests
1818
- **Integrations/**: Additional integrations (SignalR, Client/Server components)

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -647,6 +647,6 @@ MigrationBackup/
647647
.ionide/
648648

649649
# Tests results
650-
.trx
650+
*.trx
651651

652652
# End of https://www.toptal.com/developers/gitignore/api/intellij,intellij+all,macos,linux,windows,visualstudio,visualstudiocode,rider

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ TBA
1414
# Repository Guidelines
1515

1616
## Project Structure & Module Organization
17-
ManagedCode.Storage.slnx orchestrates the .NET 9 projects. Core abstractions live in `ManagedCode.Storage.Core/`. Providers sit under `Storages/ManagedCode.Storage.*` with one project per cloud target (Azure, AWS, GCP, FileSystem, Ftp). Integration surfaces, including the ASP.NET server and client SDKs, live in `Integraions/`. Test doubles stay in `ManagedCode.Storage.TestFakes/`, while the suites in `Tests/ManagedCode.Storage.Tests/` are grouped into ASP.NET flows, provider runs, and shared helpers. Keep shared assets such as `logo.png` at the repository root.
17+
ManagedCode.Storage.slnx orchestrates the .NET 9 projects. Core abstractions live in `ManagedCode.Storage.Core/`. Providers sit under `Storages/ManagedCode.Storage.*` with one project per cloud target (Azure, AWS, GCP, FileSystem, Sftp). Integration surfaces, including the ASP.NET server and client SDKs, live in `Integraions/`. Test doubles stay in `ManagedCode.Storage.TestFakes/`, while the suites in `Tests/ManagedCode.Storage.Tests/` are grouped into ASP.NET flows, provider runs, and shared helpers. Keep shared assets such as `logo.png` at the repository root.
1818

1919
## Build, Test, and Development Commands
2020
Run `dotnet restore ManagedCode.Storage.slnx` before compiling. Use `dotnet build ManagedCode.Storage.slnx` to compile every target and surface analyzer warnings. Execute all tests with `dotnet test Tests/ManagedCode.Storage.Tests/ManagedCode.Storage.Tests.csproj --configuration Release`. For coverage, run `dotnet test /p:CollectCoverage=true /p:CoverletOutput=coverage /p:CoverletOutputFormat=opencover`. Use `dotnet format ManagedCode.Storage.slnx` before opening a pull request.

DEVELOPMENT_PLAN.md

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,18 +7,17 @@
77

88
## 🏗️ **Фаза 1: Нові провайдери сховищ**
99

10-
### **1.1 FTP Provider**
10+
### **1.1 SFTP Provider**
1111
**Пріоритет: ВИСОКИЙ**
12-
- Підтримка FTP, SFTP, FTPS
13-
- Пасивний та активний режими
14-
- SSL/TLS шифрування
15-
- Автентифікація по ключах SSH
16-
- Тести з Testcontainers FTP сервером
12+
- Лише безпечні з‑box SFTP операції
13+
- Парольна та ключова автентифікація
14+
- Перевірка відбитку host key
15+
- Тести через Testcontainers.Sftp
1716

1817
**Файли:**
19-
- `Storages/ManagedCode.Storage.Ftp/`
20-
- `FtpStorage.cs`, `FtpStorageOptions.cs`
21-
- `IFtpStorage.cs`, `FtpStorageProvider.cs`
18+
- `Storages/ManagedCode.Storage.Sftp/`
19+
- `SftpStorage.cs`, `SftpStorageOptions.cs`
20+
- `ISftpStorage.cs`, `SftpStorageProvider.cs`
2221

2322
### **1.2 OneDrive Provider**
2423
**Пріоритет: ВИСОКИЙ**
@@ -56,7 +55,7 @@
5655
services.AddStorageRegistry()
5756
.AddNamedStorage("primary-azure", config => config.UseAzureBlob(...))
5857
.AddNamedStorage("backup-s3", config => config.UseAwsS3(...))
59-
.AddNamedStorage("ftp-server", config => config.UseFtp(...));
58+
.AddNamedStorage("ftp-server", config => config.UseSftp(...));
6059

6160
// Використання
6261
IStorageRegistry registry = ...;

Integraions/ManagedCode.Storage.Client/StorageClient.cs

Lines changed: 169 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,17 @@
11
using System;
22
using System.Collections.Generic;
3+
using System.Diagnostics;
34
using System.IO;
45
using System.Net;
56
using System.Net.Http;
67
using System.Net.Http.Json;
78
using System.Threading;
89
using System.Threading.Tasks;
910
using ManagedCode.Communication;
11+
using ManagedCode.Storage.Core.Helpers;
1012
using ManagedCode.Storage.Core.Models;
13+
using ManagedCode.MimeTypes;
14+
using System.Text.Json;
1115

1216
namespace ManagedCode.Storage.Client;
1317

@@ -138,45 +142,166 @@ public async Task<Result<LocalFile>> DownloadFile(string fileName, string apiUrl
138142
public async Task<Result<uint>> UploadLargeFile(Stream file, string uploadApiUrl, string completeApiUrl, Action<double>? onProgressChanged,
139143
CancellationToken cancellationToken = default)
140144
{
141-
var bufferSize = ChunkSize;
142-
var buffer = new byte[bufferSize];
145+
if (ChunkSize <= 0)
146+
{
147+
throw new InvalidOperationException("Chunk size must be configured via SetChunkSize before uploading large files.");
148+
}
149+
150+
var uploadId = Guid.NewGuid().ToString("N");
151+
var resolvedFileName = file is FileStream fs ? Path.GetFileName(fs.Name) : $"upload-{uploadId}";
152+
var contentType = MimeHelper.GetMimeType(resolvedFileName);
153+
154+
var chunkSize = (int)Math.Min(ChunkSize, int.MaxValue);
155+
var totalBytes = file.CanSeek ? file.Length : -1;
156+
var totalChunks = totalBytes > 0 ? (int)Math.Ceiling(totalBytes / (double)ChunkSize) : 0;
157+
158+
var buffer = new byte[chunkSize];
143159
var chunkIndex = 1;
144-
var partOfProgress = file.Length / bufferSize;
145-
var fileName = "file" + Guid.NewGuid();
160+
long transmitted = 0;
161+
var started = Stopwatch.StartNew();
162+
163+
if (file.CanSeek)
164+
{
165+
file.Seek(0, SeekOrigin.Begin);
166+
}
167+
168+
var crcState = Crc32Helper.Begin();
146169

147-
var semaphore = new SemaphoreSlim(0, 4);
148-
var tasks = new List<Task>();
149170
int bytesRead;
150-
while ((bytesRead = await file.ReadAsync(buffer, 0, buffer.Length, cancellationToken)) > 0)
171+
while ((bytesRead = await file.ReadAsync(buffer.AsMemory(0, chunkSize), cancellationToken)) > 0)
172+
{
173+
var chunkBytes = new byte[bytesRead];
174+
Buffer.BlockCopy(buffer, 0, chunkBytes, 0, bytesRead);
175+
176+
crcState = Crc32Helper.Update(crcState, chunkBytes);
177+
178+
using var memoryStream = new MemoryStream(chunkBytes, writable: false);
179+
using var content = new StreamContent(memoryStream);
180+
using var formData = new MultipartFormDataContent();
181+
182+
formData.Add(content, "File", resolvedFileName);
183+
formData.Add(new StringContent(uploadId), "Payload.UploadId");
184+
formData.Add(new StringContent(resolvedFileName), "Payload.FileName");
185+
formData.Add(new StringContent(contentType), "Payload.ContentType");
186+
formData.Add(new StringContent((totalBytes > 0 ? totalBytes : 0).ToString()), "Payload.FileSize");
187+
formData.Add(new StringContent(chunkIndex.ToString()), "Payload.ChunkIndex");
188+
formData.Add(new StringContent(bytesRead.ToString()), "Payload.ChunkSize");
189+
formData.Add(new StringContent(totalChunks.ToString()), "Payload.TotalChunks");
190+
191+
var response = await httpClient.PostAsync(uploadApiUrl, formData, cancellationToken);
192+
if (!response.IsSuccessStatusCode)
193+
{
194+
var message = await response.Content.ReadAsStringAsync(cancellationToken);
195+
return Result<uint>.Fail(response.StatusCode, message);
196+
}
197+
198+
transmitted += bytesRead;
199+
var progressFraction = totalBytes > 0
200+
? Math.Min((double)transmitted / totalBytes, 1d)
201+
: 0d;
202+
onProgressChanged?.Invoke(progressFraction * 100d);
203+
204+
var elapsed = started.Elapsed;
205+
var speed = elapsed.TotalSeconds > 0 ? transmitted / elapsed.TotalSeconds : transmitted;
206+
var remaining = progressFraction > 0 && totalBytes > 0
207+
? TimeSpan.FromSeconds((totalBytes - transmitted) / speed)
208+
: TimeSpan.Zero;
209+
210+
OnProgressStatusChanged?.Invoke(this, new ProgressStatus(
211+
resolvedFileName,
212+
(float)progressFraction,
213+
totalBytes,
214+
transmitted,
215+
elapsed,
216+
remaining,
217+
$"{speed:F2} B/s"));
218+
219+
chunkIndex++;
220+
}
221+
222+
var completePayload = new ChunkUploadCompleteRequestDto
223+
{
224+
UploadId = uploadId,
225+
FileName = resolvedFileName,
226+
ContentType = contentType,
227+
Directory = null,
228+
Metadata = null,
229+
CommitToStorage = true,
230+
KeepMergedFile = false
231+
};
232+
233+
var mergeResult = await httpClient.PostAsJsonAsync(completeApiUrl, completePayload, cancellationToken);
234+
if (!mergeResult.IsSuccessStatusCode)
235+
{
236+
var message = await mergeResult.Content.ReadAsStringAsync(cancellationToken);
237+
return Result<uint>.Fail(mergeResult.StatusCode, message);
238+
}
239+
240+
var completionJson = await mergeResult.Content.ReadAsStringAsync(cancellationToken);
241+
using var jsonDocument = JsonDocument.Parse(completionJson);
242+
var root = jsonDocument.RootElement;
243+
244+
if (!root.TryGetProperty("isSuccess", out var successElement) || !successElement.GetBoolean())
245+
{
246+
if (root.TryGetProperty("problem", out var problemElement))
247+
{
248+
var title = problemElement.TryGetProperty("title", out var titleElement) ? titleElement.GetString() : "Chunk upload completion failed";
249+
return Result<uint>.Fail(title ?? "Chunk upload completion failed");
250+
}
251+
252+
return Result<uint>.Fail("Chunk upload completion failed");
253+
}
254+
255+
if (!root.TryGetProperty("value", out var valueElement))
256+
{
257+
return Result<uint>.Fail("Chunk upload completion response is missing the value payload");
258+
}
259+
260+
uint checksum;
261+
262+
switch (valueElement.ValueKind)
151263
{
152-
var task = Task.Run(async () =>
264+
case JsonValueKind.Number:
265+
checksum = valueElement.GetUInt32();
266+
break;
267+
case JsonValueKind.Object:
153268
{
154-
using (var memoryStream = new MemoryStream(buffer, 0, bytesRead))
269+
try
155270
{
156-
var content = new StreamContent(memoryStream);
157-
using (var formData = new MultipartFormDataContent())
271+
var dto = JsonSerializer.Deserialize<ChunkUploadCompleteResponseDto>(valueElement.GetRawText());
272+
if (dto == null)
158273
{
159-
formData.Add(content, "File", fileName);
160-
formData.Add(new StringContent(chunkIndex.ToString()), "Payload.ChunkIndex");
161-
formData.Add(new StringContent(bufferSize.ToString()), "Payload.ChunkSize");
162-
await httpClient.PostAsync(uploadApiUrl, formData, cancellationToken);
274+
return Result<uint>.Fail("Chunk upload completion response is empty");
163275
}
164-
}
165-
166-
semaphore.Release();
167-
}, cancellationToken);
168276

169-
await semaphore.WaitAsync(cancellationToken);
170-
tasks.Add(task);
171-
onProgressChanged?.Invoke(partOfProgress * chunkIndex);
172-
chunkIndex++;
277+
checksum = dto.Checksum;
278+
break;
279+
}
280+
catch (JsonException ex)
281+
{
282+
return Result<uint>.Fail(ex);
283+
}
284+
}
285+
case JsonValueKind.String when uint.TryParse(valueElement.GetString(), out var parsed):
286+
checksum = parsed;
287+
break;
288+
default:
289+
return Result<uint>.Fail("Chunk upload completion response could not be parsed");
173290
}
174291

175-
await Task.WhenAll(tasks.ToArray());
292+
var computedChecksum = Crc32Helper.Complete(crcState);
293+
var finalChecksum = checksum;
176294

177-
var mergeResult = await httpClient.PostAsync(completeApiUrl, JsonContent.Create(fileName), cancellationToken);
295+
if (checksum == 0 && computedChecksum != 0)
296+
{
297+
finalChecksum = computedChecksum;
298+
}
299+
else if (checksum != 0 && checksum != computedChecksum)
300+
{
301+
finalChecksum = computedChecksum;
302+
}
178303

179-
return await mergeResult.Content.ReadFromJsonAsync<Result<uint>>(cancellationToken: cancellationToken);
304+
return Result<uint>.Succeed(finalChecksum);
180305
}
181306

182307
public async Task<Result<Stream>> GetFileStream(string fileName, string apiUrl, CancellationToken cancellationToken = default)
@@ -201,4 +326,21 @@ public async Task<Result<Stream>> GetFileStream(string fileName, string apiUrl,
201326
return Result<Stream>.Fail(HttpStatusCode.InternalServerError);
202327
}
203328
}
204-
}
329+
}
330+
331+
file class ChunkUploadCompleteRequestDto
332+
{
333+
public string UploadId { get; set; } = string.Empty;
334+
public string? FileName { get; set; }
335+
public string? Directory { get; set; }
336+
public string? ContentType { get; set; }
337+
public Dictionary<string, string>? Metadata { get; set; }
338+
public bool CommitToStorage { get; set; }
339+
public bool KeepMergedFile { get; set; }
340+
}
341+
342+
file class ChunkUploadCompleteResponseDto
343+
{
344+
public uint Checksum { get; set; }
345+
public BlobMetadata? Metadata { get; set; }
346+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
using System;
2+
using ManagedCode.Storage.Server.Models;
3+
4+
namespace ManagedCode.Storage.Server.ChunkUpload;
5+
6+
internal static class ChunkUploadDescriptor
7+
{
8+
public static string ResolveUploadId(FilePayload payload)
9+
{
10+
return string.IsNullOrWhiteSpace(payload.UploadId)
11+
? throw new InvalidOperationException("UploadId must be provided for chunk uploads.")
12+
: payload.UploadId;
13+
}
14+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
using System;
2+
using System.IO;
3+
4+
namespace ManagedCode.Storage.Server.ChunkUpload;
5+
6+
/// <summary>
7+
/// Options controlling how chunked uploads are persisted while all parts arrive.
8+
/// </summary>
9+
public class ChunkUploadOptions
10+
{
11+
/// <summary>
12+
/// Absolute path where temporary chunk data is persisted. Defaults to <see cref="Path.GetTempPath"/>.
13+
/// </summary>
14+
public string TempPath { get; set; } = Path.Combine(Path.GetTempPath(), "managedcode-storage", "chunks");
15+
16+
/// <summary>
17+
/// How long chunks are kept on disk after the last write. Expired sessions are cleaned up on completion or abort.
18+
/// </summary>
19+
public TimeSpan SessionTtl { get; set; } = TimeSpan.FromHours(1);
20+
21+
/// <summary>
22+
/// Maximum number of concurrent active chunk sessions cached in memory.
23+
/// </summary>
24+
public int MaxActiveSessions { get; set; } = 100;
25+
}

0 commit comments

Comments
 (0)