diff --git a/.vscode/settings.json b/.vscode/settings.json index c3e8bb8..559cfbf 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,6 +1,7 @@ { "FSharp.suggestGitignore": false, "cSpell.words": [ + "Ingestor", "useparagon" ] } \ No newline at end of file diff --git a/README.md b/README.md index 63f6f1d..140dc95 100644 --- a/README.md +++ b/README.md @@ -17,8 +17,6 @@ A application harness to explore using Paragon for integrations. .\scripts\setup-certs.ps1 ``` -> **Run this from an elevated (Admin) PowerShell prompt.** `mkcert` needs admin rights to install the CA into the JDK system trust store. Without elevation the OS and browsers still trust the cert, but Java tools will reject it. - This uses [mkcert](https://github.com/FiloSottile/mkcert) (installed automatically if missing). No hosts file entries needed — `*.paragonplayground.localhost` resolves to 127.0.0.1 natively. ### 2. Start infrastructure diff --git a/src/ParagonPlayground/backend/src/ParagonPlayground.Api/Endpoints/IntegrationEndpoints.cs b/src/ParagonPlayground/backend/src/ParagonPlayground.Api/Endpoints/IntegrationEndpoints.cs index 0ae3e8e..92f9471 100644 --- a/src/ParagonPlayground/backend/src/ParagonPlayground.Api/Endpoints/IntegrationEndpoints.cs +++ b/src/ParagonPlayground/backend/src/ParagonPlayground.Api/Endpoints/IntegrationEndpoints.cs @@ -56,7 +56,7 @@ internal static class IntegrationEndpoints HttpContext context, OrganizationIntegrationRepository repo, UserCredentialRepository credRepo, - ParagonService paragon, + ParagonApiClient paragon, CancellationToken ct ) { diff --git a/src/ParagonPlayground/backend/src/ParagonPlayground.Api/Endpoints/ParagonEndpoints.cs b/src/ParagonPlayground/backend/src/ParagonPlayground.Api/Endpoints/ParagonEndpoints.cs index f60bbfc..d27d830 100644 --- a/src/ParagonPlayground/backend/src/ParagonPlayground.Api/Endpoints/ParagonEndpoints.cs +++ b/src/ParagonPlayground/backend/src/ParagonPlayground.Api/Endpoints/ParagonEndpoints.cs @@ -16,7 +16,7 @@ internal static class ParagonEndpoints private static async Task GenerateToken( HttpContext context, - ParagonService paragon, + ParagonApiClient paragon, UserCredentialRepository credRepo, CancellationToken ct ) diff --git a/src/ParagonPlayground/backend/src/ParagonPlayground.Api/Endpoints/StorageEndpoints.cs b/src/ParagonPlayground/backend/src/ParagonPlayground.Api/Endpoints/StorageEndpoints.cs index c3be926..e3d28e6 100644 --- a/src/ParagonPlayground/backend/src/ParagonPlayground.Api/Endpoints/StorageEndpoints.cs +++ b/src/ParagonPlayground/backend/src/ParagonPlayground.Api/Endpoints/StorageEndpoints.cs @@ -12,6 +12,34 @@ namespace ParagonPlayground.Api.Endpoints; internal static class StorageEndpoints { + private static readonly Action LogWebhookTestPing = + LoggerMessage.Define( + LogLevel.Information, + new EventId(1, nameof(HandleParagonWebhook)), + "Received Paragon Webhook test/verification ping." + ); + + private static readonly Action LogNoRootItemFoundWarning = + LoggerMessage.Define( + LogLevel.Warning, + new EventId(2, nameof(HandleParagonWebhook)), + "No root item found in DB for syncId: {SyncId}" + ); + + private static readonly Action LogSyncErrored = + LoggerMessage.Define( + LogLevel.Error, + new EventId(3, nameof(HandleParagonWebhook)), + "Sync {SyncId} failed with code {Code}: {Message}" + ); + + private static readonly Action LogUnhandledWebhookEvent = + LoggerMessage.Define( + LogLevel.Information, + new EventId(4, nameof(HandleParagonWebhook)), + "Received unhandled Paragon event type: {Event}" + ); + internal static RouteGroupBuilder MapStorageEndpoints(this RouteGroupBuilder group) { _ = group.MapGet("/", ListItems); @@ -20,10 +48,150 @@ internal static class StorageEndpoints _ = group.MapDelete("/{id}", DeleteItem); _ = group.MapGet("/{id}/download", GetDownloadUrls); _ = group.MapGet("/{id}/content", ProxyContent); + _ = group.MapPost("/synced-folders", CreateSyncedFolder); + _ = group.MapPost("/webhook/paragon", HandleParagonWebhook).AllowAnonymous(); _ = group.RequireAuthorization(); return group; } + private static async Task HandleParagonWebhook( + ParagonWebhookPayload payload, + StorageItemRepository repo, + UserCredentialRepository credRepo, + ParagonApiClient paragon, + SyncHierarchyIngestor syncHierarchyIngestor, + ILoggerFactory loggerFactory, + CancellationToken ct + ) + { + var logger = loggerFactory.CreateLogger("ParagonWebhook"); + + if (payload is null || string.IsNullOrEmpty(payload.SyncInstanceId) || payload.Event is "test" or "ping") + { + LogWebhookTestPing(logger, null); + return Results.Ok(new { status = "ok", message = "Webhook endpoint active" }); + } + + var rootItem = await repo.GetByManagedSyncIdAsync(payload.SyncInstanceId, ct); + + if (rootItem is null) + { + LogNoRootItemFoundWarning(logger, payload.SyncInstanceId, null); + return Results.Ok(new { status = "ignored", reason = "Sync instance not tracked in app" }); + } + + var jwt = paragon.GenerateToken(rootItem.OrganizationId); + + switch (payload.Event) + { + case "sync_complete": + case "record_created": + case "record_updated": + await syncHierarchyIngestor.IngestSyncRecordsAsync(jwt, payload.SyncInstanceId, rootItem, ct); + break; + + case "record_deleted": + if (!string.IsNullOrEmpty(payload.Data?.RecordId)) + { + await DeleteSyncedRecordAsync(payload.Data.RecordId, rootItem, repo, ct); + } + break; + + case "sync_errored": + LogSyncErrored(logger, payload.SyncInstanceId, payload.Error?.Code, payload.Error?.Message, null); + break; + + default: + LogUnhandledWebhookEvent(logger, payload.Event, null); + break; + } + + return Results.Ok(new { status = "processed" }); + } + + private static async Task DeleteSyncedRecordAsync( + string recordId, + StorageItem rootItem, + StorageItemRepository repo, + CancellationToken ct + ) + { + StorageItem? existing = null; + + if (string.IsNullOrWhiteSpace(rootItem.ManagedSyncId) is false) + { + existing = await repo.GetByParagonRecordIdAsync( + rootItem.OrganizationId, + rootItem.ManagedSyncId, + recordId, + ct + ); + } + + existing ??= await repo.GetBySharePointDriveItemIdAsync(rootItem.OrganizationId, recordId, ct); + + if (existing is not null) + { + await repo.DeleteAsync(existing.Id, ct); + } + } + + private static async Task CreateSyncedFolder( + CreateSyncedFolderRequest request, + HttpContext context, + StorageItemRepository repo, + UserCredentialRepository credRepo, + ParagonApiClient paragon, + CancellationToken ct + ) + { + var user = context.GetUser(); + var org = context.GetOrganization(); + + var credentials = await credRepo.GetByUserIdAsync(user.Id, ct); + var spCredential = credentials.FirstOrDefault( + c => c.IntegrationType.Equals("sharepoint", StringComparison.OrdinalIgnoreCase) + ); + + var jwt = paragon.GenerateToken(org.Id, spCredential?.CredentialId); + + var folderName = await paragon.GetDriveItemNameAsync( + jwt, + spCredential?.CredentialId, + request.SharePointSiteId, + request.SharePointFolderId, + ct + ); + + var syncId = await paragon.EnableSyncAsync( + jwt, + spCredential?.CredentialId, + request.SharePointFolderId, + request.SharePointSiteId, + ct + ); + + var rootFolder = new StorageItem + { + Id = ObjectId.GenerateNewId().ToString(), + OrganizationId = org.Id, + Name = folderName, + IsFolder = true, + ParentId = request.ParentId, + IsManagedSync = true, + ManagedSyncId = syncId, + SharePointFolderId = request.SharePointFolderId, + SharePointSiteId = request.SharePointSiteId, + IsReadOnly = true, + CreatedByUserId = user.Id, + CreatedAt = DateTime.UtcNow + }; + + await repo.CreateAsync(rootFolder, ct); + + return Results.Created($"/api/storage/{rootFolder.Id}", rootFolder); + } + private static async Task ListItems( string? parentId, HttpContext context, @@ -44,19 +212,7 @@ internal static class StorageEndpoints users[uid] = u?.DisplayName ?? "Unknown"; } - return Results.Ok(items.Select(i => new StorageItemResponse - { - Id = i.Id, - Name = i.Name, - IsFolder = i.IsFolder, - ParentId = i.ParentId, - ContentType = i.ContentType, - FileSize = i.FileSize, - SharePointWebUrl = i.SharePointWebUrl, - CreatedByUserId = i.CreatedByUserId, - CreatedByDisplayName = users.GetValueOrDefault(i.CreatedByUserId, "Unknown"), - CreatedAt = i.CreatedAt, - })); + return Results.Ok(items.Select(i => StorageItemResponse.From(i, users.GetValueOrDefault(i.CreatedByUserId, "Unknown")))); } private static async Task CreateFolder( @@ -87,16 +243,7 @@ internal static class StorageEndpoints await repo.CreateAsync(item, ct); - return Results.Created($"/api/storage/{item.Id}", new StorageItemResponse - { - Id = item.Id, - Name = item.Name, - IsFolder = true, - ParentId = item.ParentId, - CreatedByUserId = item.CreatedByUserId, - CreatedByDisplayName = user.DisplayName, - CreatedAt = item.CreatedAt, - }); + return Results.Created($"/api/storage/{item.Id}", StorageItemResponse.From(item, user.DisplayName)); } private static async Task UploadFile( @@ -104,7 +251,7 @@ internal static class StorageEndpoints StorageItemRepository storageRepo, UserCredentialRepository credRepo, OrganizationIntegrationRepository configRepo, - ParagonService paragon, + ParagonApiClient paragon, UserRepository userRepo, CancellationToken ct ) @@ -206,42 +353,71 @@ internal static class StorageEndpoints await storageRepo.CreateAsync(storageItem, ct); - return Results.Created($"/api/storage/{storageItem.Id}", new StorageItemResponse - { - Id = storageItem.Id, - Name = storageItem.Name, - IsFolder = false, - ParentId = storageItem.ParentId, - ContentType = storageItem.ContentType, - FileSize = storageItem.FileSize, - SharePointWebUrl = storageItem.SharePointWebUrl, - CreatedByUserId = storageItem.CreatedByUserId, - CreatedByDisplayName = user.DisplayName, - CreatedAt = storageItem.CreatedAt, - }); + return Results.Created($"/api/storage/{storageItem.Id}", StorageItemResponse.From(storageItem, user.DisplayName)); } private static async Task DeleteItem( string id, HttpContext context, StorageItemRepository repo, + UserCredentialRepository credRepo, + ParagonApiClient paragon, CancellationToken ct ) { + var user = context.GetUser(); var org = context.GetOrganization(); - var item = await repo.GetByIdAsync(id, ct); - if (item is null || item.OrganizationId != org.Id) + if (item is null) { - return Results.Problem(detail: "Item not found", statusCode: StatusCodes.Status404NotFound); + return Results.NotFound(); + } + + if (item.IsReadOnly && !item.IsManagedSync) + { + return Results.Problem(detail: "Contents of a managed sync folder are read-only.", statusCode: StatusCodes.Status403Forbidden); + } + + if (item.IsManagedSync && !string.IsNullOrEmpty(item.ManagedSyncId)) + { + var credentials = await credRepo.GetByUserIdAsync(user.Id, ct); + var spCredential = credentials.FirstOrDefault(c => c.IntegrationType.Equals("sharepoint", StringComparison.OrdinalIgnoreCase)); + var jwt = paragon.GenerateToken(org.Id, spCredential?.CredentialId); + + await paragon.DeleteSyncAsync(jwt, item.ManagedSyncId, ct); + + await DeleteFolderRecursiveAsync(item.Id, repo, ct); + return Results.NoContent(); } await repo.DeleteAsync(id, ct); - return Results.NoContent(); } + private static async Task DeleteFolderRecursiveAsync( + string folderId, + StorageItemRepository repo, + CancellationToken ct + ) + { + var children = await repo.GetChildrenAsync(folderId, ct); + + foreach (var child in children) + { + if (child.IsFolder) + { + await DeleteFolderRecursiveAsync(child.Id, repo, ct); + } + else + { + await repo.DeleteAsync(child.Id, ct); + } + } + + await repo.DeleteAsync(folderId, ct); + } + private static async Task GetDownloadUrls( string id, HttpContext context, @@ -272,11 +448,10 @@ internal static class StorageEndpoints HttpContext context, StorageItemRepository storageRepo, UserCredentialRepository credRepo, - ParagonService paragon, + ParagonApiClient paragon, CancellationToken ct ) { - var user = context.GetUser(); var org = context.GetOrganization(); var item = await storageRepo.GetByIdAsync(id, ct); @@ -286,27 +461,6 @@ internal static class StorageEndpoints return Results.Problem(detail: "Item not found", statusCode: StatusCodes.Status404NotFound); } - if (string.IsNullOrEmpty(item.SharePointDriveItemId) || string.IsNullOrEmpty(item.SharePointSiteId)) - { - return Results.Problem( - detail: "No SharePoint reference available for this file.", - statusCode: StatusCodes.Status400BadRequest - ); - } - - var credentials = await credRepo.GetByUserIdAsync(user.Id, ct); - var spCredential = credentials.FirstOrDefault(c => - c.IntegrationType.Equals("sharepoint", StringComparison.OrdinalIgnoreCase) - ); - - if (spCredential is null) - { - return Results.Problem( - detail: "No SharePoint credential found.", - statusCode: StatusCodes.Status400BadRequest - ); - } - if (paragon.IsConfigured is false) { return Results.Problem( @@ -315,14 +469,56 @@ internal static class StorageEndpoints ); } - var jwt = paragon.GenerateToken(org.Id, spCredential.CredentialId); - var fileStream = await paragon.DownloadFileAsync( - jwt, - spCredential.CredentialId, - item.SharePointSiteId, - item.SharePointDriveItemId, - ct - ); + Stream fileStream; + + if ( + string.IsNullOrWhiteSpace(item.ManagedSyncId) is false + && string.IsNullOrWhiteSpace(item.ParagonRecordId) is false + ) + { + var jwt = paragon.GenerateToken(org.Id); + + fileStream = await paragon.DownloadSyncedRecordContentAsync( + jwt, + item.ManagedSyncId, + item.ParagonRecordId, + ct + ); + } + else + { + if (string.IsNullOrEmpty(item.SharePointDriveItemId) || string.IsNullOrEmpty(item.SharePointSiteId)) + { + return Results.Problem( + detail: "No SharePoint reference available for this file.", + statusCode: StatusCodes.Status400BadRequest + ); + } + + var user = context.GetUser(); + var credentials = await credRepo.GetByUserIdAsync(user.Id, ct); + var spCredential = credentials.FirstOrDefault(c => + c.IntegrationType.Equals("sharepoint", StringComparison.OrdinalIgnoreCase) + ); + + if (spCredential is null) + { + return Results.Problem( + detail: "No SharePoint credential found.", + statusCode: StatusCodes.Status400BadRequest + ); + } + + var jwt = paragon.GenerateToken(org.Id, spCredential.CredentialId); + + fileStream = await paragon.DownloadFileAsync( + jwt, + spCredential.CredentialId, + item.SharePointSiteId, + item.SharePointDriveItemId, + ct + ); + } return Results.Stream(fileStream, item.ContentType ?? "application/octet-stream", item.Name); } diff --git a/src/ParagonPlayground/backend/src/ParagonPlayground.Api/Program.cs b/src/ParagonPlayground/backend/src/ParagonPlayground.Api/Program.cs index b37bf2e..246b1dd 100644 --- a/src/ParagonPlayground/backend/src/ParagonPlayground.Api/Program.cs +++ b/src/ParagonPlayground/backend/src/ParagonPlayground.Api/Program.cs @@ -28,7 +28,7 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); -builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.Configure(builder.Configuration.GetSection(ParagonOptions.SectionName)); diff --git a/src/ParagonPlayground/backend/src/ParagonPlayground.Api/Services/ParagonApiClient.cs b/src/ParagonPlayground/backend/src/ParagonPlayground.Api/Services/ParagonApiClient.cs index fef60fc..5dcb0b0 100644 --- a/src/ParagonPlayground/backend/src/ParagonPlayground.Api/Services/ParagonApiClient.cs +++ b/src/ParagonPlayground/backend/src/ParagonPlayground.Api/Services/ParagonApiClient.cs @@ -1,22 +1,145 @@ using System.Net.Http.Headers; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Security.Cryptography; +using System.Text; using System.Text.Json; using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; using ParagonPlayground.Api.Options; +using ParagonPlayground.Domain.DTOs; namespace ParagonPlayground.Api.Services; internal sealed class ParagonApiClient { + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true + }; private readonly HttpClient _httpClient; - private readonly string _projectId; + private readonly string _signingKey; + public string ProjectId { get; } public ParagonApiClient(HttpClient httpClient, IOptions options) { _httpClient = httpClient; - _projectId = options.Value.ProjectId; - _httpClient.BaseAddress = new Uri(options.Value.ProxyBaseUrl.TrimEnd('/') + "/"); + var paragonOptions = options.Value; + ProjectId = paragonOptions.ProjectId; + _signingKey = paragonOptions.SigningKey; + _httpClient.BaseAddress = new Uri(paragonOptions.ProxyBaseUrl.TrimEnd('/') + "/"); + } + + + public bool IsConfigured => + string.IsNullOrEmpty(ProjectId) is false + && string.IsNullOrEmpty(_signingKey) is false; + + public string GenerateToken(string organizationId, string? credentialId = null) + { + if (IsConfigured is false) + { + throw new InvalidOperationException("Paragon is not configured. Set Paragon:ProjectId and Paragon:SigningKey."); + } + + using var rsa = RSA.Create(); + rsa.ImportFromPem(_signingKey); + + var key = new RsaSecurityKey(rsa.ExportParameters(true)) { KeyId = "paragon" }; + var signingCredentials = new SigningCredentials(key, SecurityAlgorithms.RsaSha256); + + var now = DateTime.UtcNow; + + var permissions = credentialId is not null + ? new Dictionary + { + ["integration:sharepoint"] = new Dictionary + { + [$"credential:{credentialId}"] = true, + }, + } + : (object)new Dictionary + { + ["integration:sharepoint"] = new Dictionary + { + ["credential:*"] = new[] { "credential:write" }, + }, + }; + + var claims = new[] + { + new Claim("sub", $"org:{organizationId}"), + new Claim("aud", $"useparagon.com/{ProjectId}"), + new Claim("urn:useparagon:connect:permissions", JsonSerializer.Serialize(permissions)), + }; + + var token = new JwtSecurityToken( + claims: claims, + notBefore: now, + expires: now.AddHours(1), + signingCredentials: signingCredentials + ); + + return new JwtSecurityTokenHandler().WriteToken(token); + } + + public async Task GetDriveItemNameAsync( + string jwt, + string? credentialId, + string siteId, + string folderId, + CancellationToken ct + ) + { + var url = $"projects/{ProjectId}/sdk/proxy/sharepoint/sites/{siteId}/drive/items/{folderId}"; + + using var request = new HttpRequestMessage(HttpMethod.Get, url); + _ = request.Headers.TryAddWithoutValidation("Authorization", $"Bearer {jwt}"); + + if (string.IsNullOrEmpty(credentialId) is false) + { + _ = request.Headers.TryAddWithoutValidation("X-Paragon-Credential", credentialId); + } + + using var response = await _httpClient.SendAsync(request, ct).ConfigureAwait(false); + _ = response.EnsureSuccessStatusCode(); + + var json = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); + using var doc = JsonDocument.Parse(json); + + if (doc.RootElement.TryGetProperty("output", out var output) && output.TryGetProperty("name", out var nameProp)) + { + return nameProp.GetString() ?? "Synced SharePoint Folder"; + } + + return "Synced SharePoint Folder"; + } + + public async Task PullSyncedRecordsAsync( + string jwt, + string syncId, + string? cursor, + int pageSize, + CancellationToken ct + ) + { + var url = $"https://sync.useparagon.com/api/syncs/{syncId}/records?pageSize={pageSize}"; + + if (string.IsNullOrEmpty(cursor) is false) + { + url += $"&cursor={Uri.EscapeDataString(cursor)}"; + } + + using var request = new HttpRequestMessage(HttpMethod.Get, url); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", jwt); + + using var response = await _httpClient.SendAsync(request, ct).ConfigureAwait(false); + _ = response.EnsureSuccessStatusCode(); + + var json = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); + return JsonSerializer.Deserialize(json, JsonOptions) ?? new SyncedRecordsResponse(); } public async Task UploadFileAsync( @@ -30,7 +153,7 @@ internal sealed class ParagonApiClient CancellationToken ct ) { - var url = $"projects/{_projectId}/sdk/proxy/sharepoint" + var url = $"projects/{ProjectId}/sdk/proxy/sharepoint" + $"/sites/{siteId}/drive/root:/{folderPath.Trim('/')}/{fileName}:/content"; using var ms = new MemoryStream(); @@ -60,13 +183,31 @@ internal sealed class ParagonApiClient CancellationToken ct ) { - var url = $"projects/{_projectId}/sdk/proxy/sharepoint" + var url = $"projects/{ProjectId}/sdk/proxy/sharepoint" + $"/sites/{siteId}/drive/items/{driveItemId}/content"; using var request = new HttpRequestMessage(HttpMethod.Get, url); _ = request.Headers.TryAddWithoutValidation("Authorization", $"Bearer {jwt}"); _ = request.Headers.TryAddWithoutValidation("X-Paragon-Credential", credentialId); + _ = request.Headers.TryAddWithoutValidation("X-Paragon-Use-Raw-Response", "1"); + + var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false); + _ = response.EnsureSuccessStatusCode(); + + return await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false); + } + + public async Task DownloadSyncedRecordContentAsync( + string jwt, + string syncId, + string recordId, + CancellationToken ct + ) + { + var url = $"https://sync.useparagon.com/api/syncs/{syncId}/records/{recordId}/content"; + using var request = new HttpRequestMessage(HttpMethod.Get, url); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", jwt); var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false); _ = response.EnsureSuccessStatusCode(); @@ -88,7 +229,7 @@ internal sealed class ParagonApiClient ? ":/" + string.Join("/", segments.Select(Uri.EscapeDataString)) : ""; - var proxyUrl = $"projects/{_projectId}/sdk/proxy/sharepoint/sites/{uri.Host}{encodedPath}"; + var proxyUrl = $"projects/{ProjectId}/sdk/proxy/sharepoint/sites/{uri.Host}{encodedPath}"; using var request = new HttpRequestMessage(HttpMethod.Get, proxyUrl); _ = request.Headers.TryAddWithoutValidation("Authorization", $"Bearer {jwt}"); @@ -104,4 +245,49 @@ internal sealed class ParagonApiClient var idProp = output.GetProperty("id"); return idProp.GetString() ?? ""; } + + public async Task EnableSyncAsync( + string jwt, + string? credentialId, + string folderId, + string siteId, + CancellationToken ct + ) + { + var url = "https://sync.useparagon.com/api/syncs"; + using var request = new HttpRequestMessage(HttpMethod.Post, url); + + request.Headers.Authorization = new("Bearer", jwt); + + var payload = new + { + integration = "sharepoint", + pipeline = "files", + credentialId, + configuration = new + { + folderId, + siteId, + }, + }; + + request.Content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json"); + + using var response = await _httpClient.SendAsync(request, ct).ConfigureAwait(false); + var json = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); + _ = response.EnsureSuccessStatusCode(); + + using var doc = JsonDocument.Parse(json); + return doc.RootElement.GetProperty("id").GetString() ?? throw new InvalidOperationException("Failed to obtain sync ID"); + } + + public async Task DeleteSyncAsync(string jwt, string syncId, CancellationToken ct) + { + var url = $"https://sync.useparagon.com/api/syncs/{syncId}"; + using var request = new HttpRequestMessage(HttpMethod.Delete, url); + request.Headers.Authorization = new("Bearer", jwt); + + using var response = await _httpClient.SendAsync(request, ct).ConfigureAwait(false); + _ = response.EnsureSuccessStatusCode(); + } } \ No newline at end of file diff --git a/src/ParagonPlayground/backend/src/ParagonPlayground.Api/Services/ParagonService.cs b/src/ParagonPlayground/backend/src/ParagonPlayground.Api/Services/ParagonService.cs deleted file mode 100644 index 35720dc..0000000 --- a/src/ParagonPlayground/backend/src/ParagonPlayground.Api/Services/ParagonService.cs +++ /dev/null @@ -1,106 +0,0 @@ -using System.IdentityModel.Tokens.Jwt; -using System.Security.Claims; -using System.Security.Cryptography; -using System.Text.Json; - -using Microsoft.Extensions.Options; -using Microsoft.IdentityModel.Tokens; - -using ParagonPlayground.Api.Options; - -namespace ParagonPlayground.Api.Services; - -internal sealed class ParagonService(IOptions options, ParagonApiClient apiClient) -{ - private readonly ParagonOptions _options = options.Value; - private readonly ParagonApiClient _apiClient = apiClient; - - public string ProjectId => _options.ProjectId; - - public bool IsConfigured => - string.IsNullOrEmpty(_options.ProjectId) is false - && string.IsNullOrEmpty(_options.SigningKey) is false; - - public string GenerateToken(string organizationId, string? credentialId = null) - { - if (IsConfigured is false) - { - throw new InvalidOperationException("Paragon is not configured. Set Paragon:ProjectId and Paragon:SigningKey."); - } - - using var rsa = RSA.Create(); - rsa.ImportFromPem(_options.SigningKey); - - var key = new RsaSecurityKey(rsa.ExportParameters(true)) { KeyId = "paragon" }; - var signingCredentials = new SigningCredentials(key, SecurityAlgorithms.RsaSha256); - - var now = DateTime.UtcNow; - - var permissions = credentialId is not null - ? new Dictionary - { - ["integration:sharepoint"] = new Dictionary - { - [$"credential:{credentialId}"] = true, - }, - } - : (object)new Dictionary - { - ["integration:sharepoint"] = new Dictionary - { - ["credential:*"] = new[] { "credential:write" }, - }, - }; - - var claims = new[] - { - new Claim("sub", $"org:{organizationId}"), - new Claim("aud", $"useparagon.com/{_options.ProjectId}"), - new Claim("urn:useparagon:connect:permissions", JsonSerializer.Serialize(permissions)), - }; - - var token = new JwtSecurityToken( - claims: claims, - notBefore: now, - expires: now.AddHours(1), - signingCredentials: signingCredentials - ); - - return new JwtSecurityTokenHandler().WriteToken(token); - } - - public Task UploadFileAsync( - string jwt, - string credentialId, - string siteId, - string folderPath, - string fileName, - Stream fileStream, - string contentType, - CancellationToken ct - ) - { - return _apiClient.UploadFileAsync(jwt, credentialId, siteId, folderPath, fileName, fileStream, contentType, ct); - } - - public Task ResolveSiteUrlAsync( - string jwt, - string credentialId, - string siteUrl, - CancellationToken ct - ) - { - return _apiClient.ResolveSiteUrlAsync(jwt, credentialId, siteUrl, ct); - } - - public Task DownloadFileAsync( - string jwt, - string credentialId, - string siteId, - string driveItemId, - CancellationToken ct - ) - { - return _apiClient.DownloadFileAsync(jwt, credentialId, siteId, driveItemId, ct); - } -} \ No newline at end of file diff --git a/src/ParagonPlayground/backend/src/ParagonPlayground.Api/Services/SyncHierarchyIngestor.cs b/src/ParagonPlayground/backend/src/ParagonPlayground.Api/Services/SyncHierarchyIngestor.cs new file mode 100644 index 0000000..a9e3f70 --- /dev/null +++ b/src/ParagonPlayground/backend/src/ParagonPlayground.Api/Services/SyncHierarchyIngestor.cs @@ -0,0 +1,298 @@ +using MongoDB.Bson; + +using ParagonPlayground.Domain.DTOs; +using ParagonPlayground.Domain.Entities; +using ParagonPlayground.Infrastructure.Data; + +namespace ParagonPlayground.Api.Services; + +internal sealed class SyncHierarchyIngestor(StorageItemRepository repo, ParagonApiClient paragon) +{ + private readonly StorageItemRepository _repo = repo; + private readonly ParagonApiClient _paragon = paragon; + + public async Task IngestSyncRecordsAsync( + string jwt, + string syncId, + StorageItem rootItem, + CancellationToken ct + ) + { + var fetchedRecords = await FetchRecordsAsync(jwt, syncId, ct); + var normalizedRecords = NormalizeRecords(fetchedRecords, rootItem); + + if (normalizedRecords.All.Count == 0) + { + return; + } + + var existingState = await LoadExistingStateAsync(rootItem.OrganizationId, normalizedRecords.ExternalIds, ct); + var folderResult = await PersistFoldersAsync(rootItem, syncId, normalizedRecords.Folders, existingState, ct); + await PersistUnresolvedFoldersAtRootAsync(rootItem, syncId, folderResult.UnresolvedFolders, existingState, ct); + await PersistFilesAsync(rootItem, syncId, normalizedRecords.Files, folderResult.FolderStorageIdByExternalId, existingState, ct); + } + + private async Task> FetchRecordsAsync( + string jwt, + string syncId, + CancellationToken ct + ) + { + string? cursor = null; + var hasMore = true; + var records = new List(); + + while (hasMore && !ct.IsCancellationRequested) + { + var response = await _paragon.PullSyncedRecordsAsync(jwt, syncId, cursor, 100, ct); + records.AddRange(response.Data); + + cursor = response.Paging?.Cursor; + hasMore = (response.Paging?.RemainingRecords ?? 0) > 0 && !string.IsNullOrEmpty(cursor); + } + + return records; + } + + private static NormalizedRecordSet NormalizeRecords( + IReadOnlyList records, + StorageItem rootItem + ) + { + var all = records + .Where(r => string.IsNullOrWhiteSpace(r.ExternalId) is false) + .GroupBy(r => r.ExternalId, StringComparer.Ordinal) + .Select(g => g.Last()) + .Where(r => IsRootRecord(r, rootItem) is false) + .ToList(); + + return new NormalizedRecordSet( + All: all, + Folders: [.. all.Where(r => r.IsFolder())], + Files: [.. all.Where(r => r.IsFolder() is false)], + ExternalIds: [.. all.Select(r => r.ExternalId).Distinct(StringComparer.Ordinal)] + ); + } + + private async Task LoadExistingStateAsync( + string organizationId, + IReadOnlyList externalIds, + CancellationToken ct + ) + { + var existingItems = await _repo.GetBySharePointDriveItemIdsAsync(organizationId, externalIds, ct); + return ExistingStorageState.FromItems(existingItems); + } + + private async Task PersistFoldersAsync( + StorageItem rootItem, + string syncId, + IReadOnlyList folders, + ExistingStorageState state, + CancellationToken ct + ) + { + var pending = folders.ToList(); + var folderStorageIdByExternalId = new Dictionary(state.FolderStorageIdByExternalId, StringComparer.Ordinal); + + while (pending.Count > 0 && !ct.IsCancellationRequested) + { + var progressMade = false; + var nextPending = new List(); + + foreach (var folder in pending) + { + if (TryResolveParentId(rootItem, folder, folderStorageIdByExternalId, out var parentId) is false) + { + nextPending.Add(folder); + continue; + } + + var persisted = await UpsertStorageItemAsync(rootItem, syncId, folder, isFolder: true, parentId, state.ItemsByExternalId, ct); + folderStorageIdByExternalId[folder.ExternalId] = persisted.Id; + progressMade = true; + } + + pending = nextPending; + + if (progressMade is false) + { + break; + } + } + + return new FolderResolutionResult(folderStorageIdByExternalId, pending); + } + + private async Task PersistUnresolvedFoldersAtRootAsync( + StorageItem rootItem, + string syncId, + IReadOnlyList unresolvedFolders, + ExistingStorageState state, + CancellationToken ct + ) + { + foreach (var folder in unresolvedFolders) + { + var persisted = await UpsertStorageItemAsync(rootItem, syncId, folder, isFolder: true, rootItem.Id, state.ItemsByExternalId, ct); + state.FolderStorageIdByExternalId[folder.ExternalId] = persisted.Id; + } + } + + private async Task PersistFilesAsync( + StorageItem rootItem, + string syncId, + IReadOnlyList files, + Dictionary folderStorageIdByExternalId, + ExistingStorageState state, + CancellationToken ct + ) + { + foreach (var file in files) + { + var parentId = rootItem.Id; + + if ( + IsRootParent(rootItem, file) is false + && folderStorageIdByExternalId.TryGetValue(file.ParentFolderId, out var resolvedParentId) + ) + { + parentId = resolvedParentId; + } + + _ = await UpsertStorageItemAsync(rootItem, syncId, file, isFolder: false, parentId, state.ItemsByExternalId, ct); + } + } + + private async Task UpsertStorageItemAsync( + StorageItem rootItem, + string syncId, + SyncedRecordItem record, + bool isFolder, + string parentId, + Dictionary itemsByExternalId, + CancellationToken ct + ) + { + if (itemsByExternalId.TryGetValue(record.ExternalId, out var existing)) + { + existing.Name = record.Name; + existing.IsFolder = isFolder; + existing.ParentId = parentId; + existing.ContentType = isFolder ? null : record.MimeType; + existing.FileSize = isFolder ? 0 : record.Size; + existing.SharePointSiteId = rootItem.SharePointSiteId; + existing.SharePointDriveItemId = record.ExternalId; + existing.SharePointWebUrl = record.Url; + existing.IsManagedSync = false; + existing.ManagedSyncId = syncId; + existing.ParagonRecordId = record.Id; + existing.IsReadOnly = true; + + await _repo.UpdateAsync(existing, ct); + return existing; + } + + var newItem = new StorageItem + { + Id = ObjectId.GenerateNewId().ToString(), + OrganizationId = rootItem.OrganizationId, + Name = record.Name, + IsFolder = isFolder, + ParentId = parentId, + ContentType = isFolder ? null : record.MimeType, + FileSize = isFolder ? 0 : record.Size, + SharePointSiteId = rootItem.SharePointSiteId, + SharePointDriveItemId = record.ExternalId, + SharePointWebUrl = record.Url, + IsManagedSync = false, + ManagedSyncId = syncId, + ParagonRecordId = record.Id, + IsReadOnly = true, + CreatedByUserId = rootItem.CreatedByUserId, + CreatedAt = DateTime.UtcNow + }; + + await _repo.CreateAsync(newItem, ct); + itemsByExternalId[record.ExternalId] = newItem; + return newItem; + } + + private static bool TryResolveParentId( + StorageItem rootItem, + SyncedRecordItem record, + Dictionary folderStorageIdByExternalId, + out string parentId + ) + { + if (IsRootParent(rootItem, record)) + { + parentId = rootItem.Id; + return true; + } + + if (folderStorageIdByExternalId.TryGetValue(record.ParentFolderId, out var resolvedParentId)) + { + parentId = resolvedParentId; + return true; + } + + parentId = string.Empty; + return false; + } + + private static bool IsRootRecord(SyncedRecordItem record, StorageItem rootItem) + { + return string.Equals(record.ExternalId, rootItem.SharePointFolderId, StringComparison.OrdinalIgnoreCase) + || string.Equals(record.ExternalId, rootItem.SharePointDriveItemId, StringComparison.OrdinalIgnoreCase); + } + + private static bool IsRootParent(StorageItem rootItem, SyncedRecordItem record) + { + return string.IsNullOrWhiteSpace(record.ParentFolderId) + || string.Equals(record.ParentFolderId, rootItem.SharePointFolderId, StringComparison.OrdinalIgnoreCase) + || string.Equals(record.ParentFolderId, rootItem.SharePointDriveItemId, StringComparison.OrdinalIgnoreCase); + } + + private sealed record NormalizedRecordSet( + IReadOnlyList All, + IReadOnlyList Folders, + IReadOnlyList Files, + IReadOnlyList ExternalIds + ); + + private sealed class ExistingStorageState + { + public Dictionary ItemsByExternalId { get; } + public Dictionary FolderStorageIdByExternalId { get; } + + private ExistingStorageState( + Dictionary itemsByExternalId, + Dictionary folderStorageIdByExternalId + ) + { + ItemsByExternalId = itemsByExternalId; + FolderStorageIdByExternalId = folderStorageIdByExternalId; + } + + public static ExistingStorageState FromItems(IEnumerable items) + { + var itemList = items.ToList(); + + var itemsByExternalId = itemList + .Where(i => string.IsNullOrWhiteSpace(i.SharePointDriveItemId) is false) + .ToDictionary(i => i.SharePointDriveItemId!, StringComparer.Ordinal); + + var folderStorageIdByExternalId = itemList + .Where(i => i.IsFolder && string.IsNullOrWhiteSpace(i.SharePointDriveItemId) is false) + .ToDictionary(i => i.SharePointDriveItemId!, i => i.Id, StringComparer.Ordinal); + + return new ExistingStorageState(itemsByExternalId, folderStorageIdByExternalId); + } + } + + private sealed record FolderResolutionResult( + Dictionary FolderStorageIdByExternalId, + IReadOnlyList UnresolvedFolders + ); +} diff --git a/src/ParagonPlayground/backend/src/ParagonPlayground.Cli/Commands/SeedCommand.cs b/src/ParagonPlayground/backend/src/ParagonPlayground.Cli/Commands/SeedCommand.cs index f7e8361..a31d3ac 100644 --- a/src/ParagonPlayground/backend/src/ParagonPlayground.Cli/Commands/SeedCommand.cs +++ b/src/ParagonPlayground/backend/src/ParagonPlayground.Cli/Commands/SeedCommand.cs @@ -12,9 +12,12 @@ namespace ParagonPlayground.Cli.Commands; internal class SeedCommand( OrganizationRepository orgRepo, UserRepository userRepo, - PasswordService passwordService) : AsyncCommand + PasswordService passwordService +) : AsyncCommand { - internal class Settings : CommandSettings { } + internal class Settings : CommandSettings + { + } protected override async Task ExecuteAsync(CommandContext context, Settings settings, CancellationToken cancellationToken) { diff --git a/src/ParagonPlayground/backend/src/ParagonPlayground.Domain/DTOs/CreateSyncedFolderRequest.cs b/src/ParagonPlayground/backend/src/ParagonPlayground.Domain/DTOs/CreateSyncedFolderRequest.cs new file mode 100644 index 0000000..0c2c312 --- /dev/null +++ b/src/ParagonPlayground/backend/src/ParagonPlayground.Domain/DTOs/CreateSyncedFolderRequest.cs @@ -0,0 +1,17 @@ +namespace ParagonPlayground.Domain.DTOs; + +/// Represents a request to sync a folder from a sharepoint site. +public class CreateSyncedFolderRequest +{ + /// The sharepoint id for the folder that will be synced. + public required string SharePointFolderId { get; set; } + + /// The id of the sharepoint site where the folder to be synced exists. + public required string SharePointSiteId { get; set; } + + /// The id of the parent id for the folder being synced. + public string ParentId { get; set; } = string.Empty; + + /// The associated credentials with the folder sync. + public string CredentialId { get; set; } = string.Empty; +} \ No newline at end of file diff --git a/src/ParagonPlayground/backend/src/ParagonPlayground.Domain/DTOs/ParagonWebhookPayload.cs b/src/ParagonPlayground/backend/src/ParagonPlayground.Domain/DTOs/ParagonWebhookPayload.cs new file mode 100644 index 0000000..9280cee --- /dev/null +++ b/src/ParagonPlayground/backend/src/ParagonPlayground.Domain/DTOs/ParagonWebhookPayload.cs @@ -0,0 +1,60 @@ +namespace ParagonPlayground.Domain.DTOs; + +/// Webhook payload sent by Paragon for managed sync lifecycle events. +public class ParagonWebhookPayload +{ + + /// Webhook event type (for example: sync_complete, record_updated, record_deleted). + public string Event { get; set; } = string.Empty; + + /// Paragon managed sync instance identifier associated with this event. + public string SyncInstanceId { get; set; } = string.Empty; + + /// Sync descriptor from the webhook payload, when provided. + public string Sync { get; set; } = string.Empty; + + /// Credential identifier used by the integration, when included by Paragon. + public string CredentialId { get; set; } = string.Empty; + + /// User context attached to the webhook payload. + public ParagonWebhookUser? User { get; set; } + + /// Event-specific data payload. + public ParagonWebhookData? Data { get; set; } + + /// Error payload when the webhook event represents a failure state. + public ParagonWebhookError? Error { get; set; } +} + +/// User metadata included in a Paragon webhook. +public class ParagonWebhookUser +{ + /// Paragon user identifier for the connected user. + public string Id { get; set; } = string.Empty; +} + +/// Event data section of a Paragon webhook payload. +public class ParagonWebhookData +{ + /// Logical model type for the emitted record event. + public string Model { get; set; } = string.Empty; + + /// Record identifier relevant to create/update/delete record events. + public string? RecordId { get; set; } + + /// Timestamp indicating when the sync data was last synchronized. + public string? SyncedAt { get; set; } + + /// Number of records referenced by the event when available. + public int? NumRecords { get; set; } +} + +/// Error details included with failed sync webhook events. +public class ParagonWebhookError +{ + /// Provider-specific error code. + public string? Code { get; set; } + + /// Human-readable error description. + public string? Message { get; set; } +} \ No newline at end of file diff --git a/src/ParagonPlayground/backend/src/ParagonPlayground.Domain/DTOs/StorageItemResponse.cs b/src/ParagonPlayground/backend/src/ParagonPlayground.Domain/DTOs/StorageItemResponse.cs index ea55bef..3beed98 100644 --- a/src/ParagonPlayground/backend/src/ParagonPlayground.Domain/DTOs/StorageItemResponse.cs +++ b/src/ParagonPlayground/backend/src/ParagonPlayground.Domain/DTOs/StorageItemResponse.cs @@ -1,3 +1,5 @@ +using ParagonPlayground.Domain.Entities; + namespace ParagonPlayground.Domain.DTOs; /// File or folder returned by the storage API. @@ -24,6 +26,12 @@ public class StorageItemResponse /// Direct SharePoint web URL (null for folders). public string? SharePointWebUrl { get; set; } + /// Indicates if the storage item is associated with a managed sync. + public bool IsManagedSync { get; set; } + + /// Indicates if the storage item can be edited by our application. + public bool IsReadOnly { get; set; } + /// User who created this item. public string CreatedByUserId { get; set; } = string.Empty; @@ -32,4 +40,27 @@ public class StorageItemResponse /// Timestamp when the item was created. public DateTime CreatedAt { get; set; } + + /// Creates a response DTO from a storage item entity. + public static StorageItemResponse From(StorageItem item, string createdByDisplayName) + { + ArgumentNullException.ThrowIfNull(item); + ArgumentNullException.ThrowIfNull(createdByDisplayName); + + return new StorageItemResponse + { + Id = item.Id, + Name = item.Name, + IsFolder = item.IsFolder, + ParentId = item.ParentId, + ContentType = item.ContentType, + FileSize = item.FileSize, + SharePointWebUrl = item.SharePointWebUrl, + IsManagedSync = item.IsManagedSync, + IsReadOnly = item.IsReadOnly, + CreatedByUserId = item.CreatedByUserId, + CreatedByDisplayName = createdByDisplayName, + CreatedAt = item.CreatedAt, + }; + } } \ No newline at end of file diff --git a/src/ParagonPlayground/backend/src/ParagonPlayground.Domain/DTOs/SyncedRecordsResponse.cs b/src/ParagonPlayground/backend/src/ParagonPlayground.Domain/DTOs/SyncedRecordsResponse.cs new file mode 100644 index 0000000..61c758f --- /dev/null +++ b/src/ParagonPlayground/backend/src/ParagonPlayground.Domain/DTOs/SyncedRecordsResponse.cs @@ -0,0 +1,84 @@ +using System.Collections.ObjectModel; +using System.Text.Json.Serialization; + +namespace ParagonPlayground.Domain.DTOs; + +/// Response payload returned by the Paragon Sync API GET /api/syncs/{syncId}/records endpoint. +public class SyncedRecordsResponse +{ + /// Synced records returned for the requested page. + [JsonPropertyName("data")] + public Collection Data { get; init; } = []; + + /// Paging metadata used to continue retrieving additional records. + [JsonPropertyName("paging")] + public SyncedRecordsPaging Paging { get; set; } = new(); +} + + +/// Pagination metadata returned with synced record batches. +public class SyncedRecordsPaging +{ + /// Total number of records known to the sync. + [JsonPropertyName("totalRecords")] + public int TotalRecords { get; set; } + + /// Total number of active (non-deleted) records in the sync. + [JsonPropertyName("totalActiveRecords")] + public int TotalActiveRecords { get; set; } + + /// Count of records still remaining after the current page. + [JsonPropertyName("remainingRecords")] + public int RemainingRecords { get; set; } + + /// Cursor to request the next page of records. + [JsonPropertyName("cursor")] + public string? Cursor { get; set; } + + /// Unix timestamp of the latest record observed in this page. + [JsonPropertyName("lastSeen")] + public long LastSeen { get; set; } +} + + +/// Represents a single synced file or record item returned by Paragon Managed Sync. +public class SyncedRecordItem +{ + /// Paragon-generated sync record identifier. + [JsonPropertyName("id")] + public string Id { get; set; } = string.Empty; + + /// Provider-native record identifier (for example, SharePoint drive item ID). + [JsonPropertyName("externalId")] + public string ExternalId { get; set; } = string.Empty; + + /// Display name for the synced file or folder. + [JsonPropertyName("name")] + public string Name { get; set; } = "Untitled Item"; + + /// MIME type of the synced file; empty for folders. + [JsonPropertyName("mimeType")] + public string MimeType { get; set; } = "application/octet-stream"; + + /// File size in bytes. + [JsonPropertyName("size")] + public long Size { get; set; } + + /// Provider URL for viewing the record in the source system. + [JsonPropertyName("url")] + public string Url { get; set; } = string.Empty; + + /// External identifier of the record's parent folder. + [JsonPropertyName("parentFolderId")] + public string ParentFolderId { get; set; } = string.Empty; + + /// Integration-specific metadata attached to the synced record. + [JsonPropertyName("customFields")] + public Dictionary? CustomFields { get; init; } + + /// Determines whether the synced item represents a folder. + public bool IsFolder() + { + return string.IsNullOrEmpty(MimeType); + } +} \ No newline at end of file diff --git a/src/ParagonPlayground/backend/src/ParagonPlayground.Domain/Entities/StorageItem.cs b/src/ParagonPlayground/backend/src/ParagonPlayground.Domain/Entities/StorageItem.cs index 448b6d2..35a357f 100644 --- a/src/ParagonPlayground/backend/src/ParagonPlayground.Domain/Entities/StorageItem.cs +++ b/src/ParagonPlayground/backend/src/ParagonPlayground.Domain/Entities/StorageItem.cs @@ -24,6 +24,7 @@ public class StorageItem /// File size in bytes (0 for folders). public long FileSize { get; set; } + /// SharePoint site ID where the file was uploaded (null for folders). public string? SharePointSiteId { get; set; } @@ -33,6 +34,23 @@ public class StorageItem /// SharePoint web URL for direct access (null for folders). public string? SharePointWebUrl { get; set; } + + /// Indicates whether the storage item is associated with a managed sync + public bool IsManagedSync { get; set; } + + /// Identifies the managed sync this storage item is attached to. + public string ManagedSyncId { get; set; } = string.Empty; + + /// Paragon Sync record ID (sync-generated UUID) for items ingested from managed sync. + public string ParagonRecordId { get; set; } = string.Empty; + + /// Identifies the sharepoint folder id that the managed sync is attached to. + public string SharePointFolderId { get; set; } = string.Empty; + + /// Indicates whether the storage item can be edited or modified by our application. + public bool IsReadOnly { get; set; } + + /// User who created this item. public string CreatedByUserId { get; set; } = string.Empty; diff --git a/src/ParagonPlayground/backend/src/ParagonPlayground.Infrastructure/Data/StorageItemRepository.cs b/src/ParagonPlayground/backend/src/ParagonPlayground.Infrastructure/Data/StorageItemRepository.cs index c1909f3..162ac02 100644 --- a/src/ParagonPlayground/backend/src/ParagonPlayground.Infrastructure/Data/StorageItemRepository.cs +++ b/src/ParagonPlayground/backend/src/ParagonPlayground.Infrastructure/Data/StorageItemRepository.cs @@ -11,7 +11,10 @@ public class StorageItemRepository(MongoDbContext context) /// Lists items in a folder (or root items when parentId is null). public async Task> GetByParentIdAsync( - string organizationId, string? parentId, CancellationToken ct) + string organizationId, + string? parentId, + CancellationToken ct + ) { var filter = Builders.Filter.Eq(i => i.OrganizationId, organizationId) & Builders.Filter.Eq(i => i.ParentId, parentId); @@ -23,6 +26,14 @@ public class StorageItemRepository(MongoDbContext context) .ConfigureAwait(false); } + /// Lists all direct child items of a given parent folder. + public async Task> GetChildrenAsync(string parentId, CancellationToken ct) + { + return await _context.StorageItems.Find(i => i.ParentId == parentId) + .ToListAsync(ct) + .ConfigureAwait(false); + } + /// Finds a storage item by ID. public async Task GetByIdAsync(string id, CancellationToken ct) { @@ -31,6 +42,68 @@ public class StorageItemRepository(MongoDbContext context) .ConfigureAwait(false); } + /// Finds a root storage item by Paragon Managed Sync ID. + public async Task GetByManagedSyncIdAsync(string managedSyncId, CancellationToken ct) + { + return await _context.StorageItems.Find(i => i.ManagedSyncId == managedSyncId) + .FirstOrDefaultAsync(ct) + .ConfigureAwait(false); + } + + /// Finds a storage item by organization ID and SharePoint Drive Item ID. + public async Task GetBySharePointDriveItemIdAsync( + string organizationId, + string sharePointDriveItemId, + CancellationToken ct + ) + { + var filter = Builders.Filter.Eq(i => i.OrganizationId, organizationId) + & Builders.Filter.Eq(i => i.SharePointDriveItemId, sharePointDriveItemId); + + return await _context.StorageItems.Find(filter) + .FirstOrDefaultAsync(ct) + .ConfigureAwait(false); + } + + /// Finds a synced storage item by organization, sync ID, and Paragon record ID. + public async Task GetByParagonRecordIdAsync( + string organizationId, + string managedSyncId, + string paragonRecordId, + CancellationToken ct + ) + { + var filter = Builders.Filter.Eq(i => i.OrganizationId, organizationId) + & Builders.Filter.Eq(i => i.ManagedSyncId, managedSyncId) + & Builders.Filter.Eq(i => i.ParagonRecordId, paragonRecordId); + + return await _context.StorageItems.Find(filter) + .FirstOrDefaultAsync(ct) + .ConfigureAwait(false); + } + + /// Finds storage items by organization ID and SharePoint Drive Item IDs. + public async Task> GetBySharePointDriveItemIdsAsync( + string organizationId, + IReadOnlyCollection sharePointDriveItemIds, + CancellationToken ct + ) + { + ArgumentNullException.ThrowIfNull(sharePointDriveItemIds); + + if (sharePointDriveItemIds.Count == 0) + { + return []; + } + + var filter = Builders.Filter.Eq(i => i.OrganizationId, organizationId) + & Builders.Filter.In(i => i.SharePointDriveItemId, sharePointDriveItemIds); + + return await _context.StorageItems.Find(filter) + .ToListAsync(ct) + .ConfigureAwait(false); + } + /// Creates a new storage item. public async Task CreateAsync(StorageItem item, CancellationToken ct) { @@ -41,6 +114,16 @@ public class StorageItemRepository(MongoDbContext context) .ConfigureAwait(false); } + /// Updates an existing storage item. + public async Task UpdateAsync(StorageItem item, CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(item); + + _ = await _context.StorageItems + .ReplaceOneAsync(i => i.Id == item.Id, item, cancellationToken: ct) + .ConfigureAwait(false); + } + /// Deletes a storage item by ID. public async Task DeleteAsync(string id, CancellationToken ct) { diff --git a/src/ParagonPlayground/frontend/src/components/AppIcon.vue b/src/ParagonPlayground/frontend/src/components/AppIcon.vue index 97b5157..8809148 100644 --- a/src/ParagonPlayground/frontend/src/components/AppIcon.vue +++ b/src/ParagonPlayground/frontend/src/components/AppIcon.vue @@ -1,5 +1,6 @@ diff --git a/src/ParagonPlayground/frontend/src/services/auth.ts b/src/ParagonPlayground/frontend/src/services/auth.ts index 1733d06..69d8633 100644 --- a/src/ParagonPlayground/frontend/src/services/auth.ts +++ b/src/ParagonPlayground/frontend/src/services/auth.ts @@ -1,6 +1,6 @@ import { api } from './api'; -export interface UserResponse { +export type UserResponse = { id: string; email: string; displayName: string; diff --git a/src/ParagonPlayground/frontend/src/services/integration.ts b/src/ParagonPlayground/frontend/src/services/integration.ts index df3c78b..0a1fc9e 100644 --- a/src/ParagonPlayground/frontend/src/services/integration.ts +++ b/src/ParagonPlayground/frontend/src/services/integration.ts @@ -1,11 +1,11 @@ import { api } from './api'; -export interface ParagonTokenResponse { +export type ParagonTokenResponse = { paragonJwt: string; projectId: string; } -export interface IntegrationConfig { +export type IntegrationConfig = { id: string; organizationId: string; connectionMode: string; @@ -15,20 +15,20 @@ export interface IntegrationConfig { updatedAt: string; } -export interface IntegrationConfigRequest { +export type IntegrationConfigRequest = { connectionMode: string; sharePointSiteUrl: string | null; sharePointFolderPath: string | null; } -export interface CredentialResponse { +export type CredentialResponse = { id: string; credentialId: string; integrationType: string; connectedAt: string; } -export interface CredentialRequest { +export type CredentialRequest = { credentialId: string; integrationType: string; } diff --git a/src/ParagonPlayground/frontend/src/services/storage.ts b/src/ParagonPlayground/frontend/src/services/storage.ts index db4e6dc..9169f0a 100644 --- a/src/ParagonPlayground/frontend/src/services/storage.ts +++ b/src/ParagonPlayground/frontend/src/services/storage.ts @@ -1,6 +1,6 @@ import { api } from './api'; -export interface StorageItem { +export type StorageItem = { id: string; name: string; isFolder: boolean; @@ -11,18 +11,35 @@ export interface StorageItem { createdByUserId: string; createdByDisplayName: string; createdAt: string; + isReadOnly: boolean; + isManagedSync: boolean; } -export interface CreateFolderRequest { +export type CreateFolderRequest = { name: string; parentId: string | null; } -export interface DownloadResponse { +export type DownloadResponse = { sharePointUrl: string | null; proxyUrl: string | null; } +export type CreateSyncedFolderRequest = { + sharePointFolderId: string; + sharePointSiteId: string; + parentId?: string | null; + credentialId?: string; +} + +export async function createSyncedFolder(req: CreateSyncedFolderRequest): Promise { + return api('/storage/synced-folders', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(req), + }); +} + export async function getItems(parentId?: string | null): Promise { const params = parentId ? `?parentId=${encodeURIComponent(parentId)}` : ''; return api(`/storage${params}`); diff --git a/src/ParagonPlayground/frontend/src/styles/main.css b/src/ParagonPlayground/frontend/src/styles/main.css index 6af6cc3..beedddc 100644 --- a/src/ParagonPlayground/frontend/src/styles/main.css +++ b/src/ParagonPlayground/frontend/src/styles/main.css @@ -129,7 +129,8 @@ select:focus { transform: translateY(1px); } -.btn:disabled { +.btn:disabled, +.btn.disabled { opacity: 0.55; cursor: not-allowed; } diff --git a/src/ParagonPlayground/frontend/src/views/FileExplorerPage.vue b/src/ParagonPlayground/frontend/src/views/FileExplorerPage.vue index c59ee79..1af7105 100644 --- a/src/ParagonPlayground/frontend/src/views/FileExplorerPage.vue +++ b/src/ParagonPlayground/frontend/src/views/FileExplorerPage.vue @@ -9,8 +9,44 @@ deleteItem, getDownloadUrls, type StorageItem, + createSyncedFolder, } from '../services/storage'; import { formatLocaleDate } from '../utils/utils'; + import { getParagonToken } from '../services/integration'; + + type SharePointIds = { + listId: string; + webId: string; + siteId: string; + listItemId: string; + listItemUniqueId: string; + }; + + type ParentReference = { + driveId: string; + sharepointIds: SharePointIds; + }; + + type SelectedFilePickerItem = { + id: string; + parentReference: ParentReference; + sharepointIds: SharePointIds; + }; + + type ParagonPickerInstance = { + init: () => Promise; + open: () => void; + }; + + type ParagonSDKWithPicker = { + authenticate: (projectId: string, jwt: string) => Promise; + ExternalFilePicker: new ( + integration: string, + options: { + onFileSelect: (items: SelectedFilePickerItem[]) => Promise | void; + }, + ) => ParagonPickerInstance; + }; const route = useRoute(); const router = useRouter(); @@ -18,13 +54,14 @@ const items = ref([]); const loading = ref(true); const error = ref(''); - const currentFolderId = ref(null); const breadcrumbs = ref<{ id: string | null; name: string }[]>([]); const showNewFolder = ref(false); const newFolderName = ref(''); + const currentFolder = ref(null); const folders = computed(() => items.value.filter((i) => i.isFolder)); const files = computed(() => items.value.filter((i) => !i.isFolder)); + const isCurrentFolderReadOnly = computed(() => currentFolder.value?.isReadOnly ?? false); const pathSegments = computed(() => { const raw = route.params.pathMatch; @@ -51,6 +88,7 @@ try { let parentId: string | null = null; const crumbs: { id: string | null; name: string }[] = []; + let resolvedFolder: StorageItem | null = null; for (const name of segments) { const children = await getItems(parentId); @@ -64,15 +102,17 @@ crumbs.push({ id: folder.id, name: folder.name }); parentId = folder.id; + resolvedFolder = folder; } - currentFolderId.value = parentId; + currentFolder.value = resolvedFolder; breadcrumbs.value = crumbs; - items.value = await getItems(currentFolderId.value); + items.value = await getItems(currentFolder.value?.id ?? null); } catch (e: unknown) { error.value = e instanceof Error ? e.message : 'Failed to navigate'; items.value = []; + currentFolder.value = null; } finally { loading.value = false; } @@ -94,7 +134,7 @@ try { await createFolder({ name: newFolderName.value.trim(), - parentId: currentFolderId.value, + parentId: currentFolder.value?.id ?? null, }); newFolderName.value = ''; @@ -115,7 +155,7 @@ } try { - await uploadFile(file, currentFolderId.value); + await uploadFile(file, currentFolder.value?.id ?? null); input.value = ''; @@ -167,6 +207,39 @@ if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`; return `${(bytes / 1048576).toFixed(1)} MB`; } + + async function openSharePointFolderPicker() { + try { + const tokenResponse = await getParagonToken(); + const { paragon } = await import('@useparagon/connect'); + const sdk = paragon as unknown as ParagonSDKWithPicker; + + await sdk.authenticate(tokenResponse.projectId, tokenResponse.paragonJwt); + + const picker = new sdk.ExternalFilePicker('sharepoint', { + onFileSelect: async (selectedItems: SelectedFilePickerItem[]) => { + if (!selectedItems || selectedItems.length === 0) return; + + const selectedItem = selectedItems[0]; + const folderId = selectedItem.id; + const siteId = selectedItem.sharepointIds.siteId; + + await createSyncedFolder({ + sharePointFolderId: folderId, + sharePointSiteId: siteId, + parentId: currentFolder.value?.id ?? null, + }); + + await navigateToPath(pathSegments.value); + }, + }); + + await picker.init(); + picker.open(); + } catch (e: unknown) { + error.value = e instanceof Error ? e.message : 'Failed to open SharePoint picker'; + } + }