feat: add ability to create folders and files and persist files to sharepoint
This commit is contained in:
@@ -1,22 +1,8 @@
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||
# === Development stage ===
|
||||
# Hot reload via dotnet watch with source volume mount
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS development
|
||||
WORKDIR /src
|
||||
|
||||
COPY ParagonPlayground.slnx ./
|
||||
COPY Directory.Build.props ./
|
||||
COPY src/Directory.Build.props src/
|
||||
COPY src/Directory.Packages.props src/
|
||||
COPY src/ParagonPlayground.Domain/ParagonPlayground.Domain.csproj src/ParagonPlayground.Domain/
|
||||
COPY src/ParagonPlayground.Infrastructure/ParagonPlayground.Infrastructure.csproj src/ParagonPlayground.Infrastructure/
|
||||
COPY src/ParagonPlayground.Api/ParagonPlayground.Api.csproj src/ParagonPlayground.Api/
|
||||
RUN dotnet restore src/ParagonPlayground.Api/ParagonPlayground.Api.csproj
|
||||
|
||||
COPY . .
|
||||
RUN dotnet publish src/ParagonPlayground.Api/ParagonPlayground.Api.csproj -c Debug -o /app
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
|
||||
WORKDIR /app
|
||||
EXPOSE 8080
|
||||
COPY --from=build /app .
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install -y unzip curl && \
|
||||
@@ -24,4 +10,30 @@ RUN apt-get update && \
|
||||
apt-get clean && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
EXPOSE 8080
|
||||
ENV ASPNETCORE_URLS=http://+:8080
|
||||
ENV ASPNETCORE_ENVIRONMENT=Development
|
||||
CMD ["dotnet", "watch", "run", "--project", "src/ParagonPlayground.Api/ParagonPlayground.Api.csproj", "--no-launch-profile"]
|
||||
|
||||
# === Build stage ===
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||
WORKDIR /src
|
||||
COPY . .
|
||||
RUN dotnet publish src/ParagonPlayground.Api/ParagonPlayground.Api.csproj -c Debug -o /app
|
||||
|
||||
# === Debug stage ===
|
||||
# Compiled binary with vsdbg for debugger attachment
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS debug
|
||||
WORKDIR /app
|
||||
EXPOSE 8080
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install -y unzip curl && \
|
||||
curl -sSL https://aka.ms/getvsdbgsh | /bin/sh /dev/stdin -v latest -l /vsdbg && \
|
||||
apt-get clean && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=build /app .
|
||||
ENV ASPNETCORE_URLS=http://+:8080
|
||||
ENV ASPNETCORE_ENVIRONMENT=Development
|
||||
ENTRYPOINT ["dotnet", "ParagonPlayground.Api.dll"]
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
<PackageVersion Include="BCrypt.Net-Next" Version="4.2.0" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Http.Abstractions" Version="2.3.11" />
|
||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.10" />
|
||||
<PackageVersion Include="System.IdentityModel.Tokens.Jwt" Version="8.4.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Http.Resilience" Version="10.1.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
namespace ParagonPlayground.Api.Auth;
|
||||
|
||||
internal static class PolicyNames
|
||||
{
|
||||
internal const string AdminOnly = "AdminOnly";
|
||||
}
|
||||
|
||||
internal static class RoleNames
|
||||
{
|
||||
internal const string Admin = "admin";
|
||||
}
|
||||
|
||||
internal static class SessionAuthDefaults
|
||||
{
|
||||
internal const string Scheme = "Session";
|
||||
}
|
||||
|
||||
internal static class ClaimNames
|
||||
{
|
||||
internal const string OrganizationId = "OrganizationId";
|
||||
|
||||
internal const string OrganizationName = "OrganizationName";
|
||||
|
||||
internal const string OrganizationSlug = "OrganizationSlug";
|
||||
}
|
||||
|
||||
internal static class EndpointAuthorizationExtensions
|
||||
{
|
||||
internal static RouteHandlerBuilder RequireAdmin(this RouteHandlerBuilder builder)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(builder);
|
||||
|
||||
_ = builder.RequireAuthorization(PolicyNames.AdminOnly);
|
||||
return builder;
|
||||
}
|
||||
}
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
using System.Security.Claims;
|
||||
using System.Text.Encodings.Web;
|
||||
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
using ParagonPlayground.Api.Context;
|
||||
using ParagonPlayground.Api.Infrastructure;
|
||||
using ParagonPlayground.Infrastructure.Data;
|
||||
using ParagonPlayground.Infrastructure.Services;
|
||||
|
||||
namespace ParagonPlayground.Api.Auth;
|
||||
|
||||
internal sealed class SessionAuthenticationHandler(
|
||||
IOptionsMonitor<AuthenticationSchemeOptions> options,
|
||||
ILoggerFactory logger,
|
||||
UrlEncoder encoder,
|
||||
CookieService cookieService,
|
||||
SessionRepository sessionRepository,
|
||||
UserRepository userRepository,
|
||||
OrganizationRepository organizationRepository
|
||||
) : AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
|
||||
{
|
||||
private readonly CookieService _cookieService = cookieService;
|
||||
private readonly SessionRepository _sessionRepository = sessionRepository;
|
||||
private readonly UserRepository _userRepository = userRepository;
|
||||
private readonly OrganizationRepository _organizationRepository = organizationRepository;
|
||||
|
||||
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||
{
|
||||
var sessionToken = _cookieService.GetSessionToken(Context);
|
||||
|
||||
if (string.IsNullOrEmpty(sessionToken))
|
||||
{
|
||||
return AuthenticateResult.NoResult();
|
||||
}
|
||||
|
||||
var tokenHash = TokenHelper.HashToken(sessionToken);
|
||||
var session = await _sessionRepository.GetByTokenHashAsync(tokenHash, Context.RequestAborted);
|
||||
|
||||
if (session is null || session.ExpiresAt <= DateTime.UtcNow)
|
||||
{
|
||||
return AuthenticateResult.Fail("Session token is invalid or expired.");
|
||||
}
|
||||
|
||||
var user = await _userRepository.GetByIdAsync(session.UserId, Context.RequestAborted);
|
||||
|
||||
if (user is null)
|
||||
{
|
||||
return AuthenticateResult.Fail("The user associated with this session no longer exists.");
|
||||
}
|
||||
|
||||
var organization = await _organizationRepository.GetByIdAsync(user.OrganizationId, Context.RequestAborted);
|
||||
|
||||
if (organization is null)
|
||||
{
|
||||
return AuthenticateResult.Fail("The organization associated with this session no longer exists.");
|
||||
}
|
||||
|
||||
Context.SetSession(session);
|
||||
Context.SetSessionToken(sessionToken);
|
||||
Context.SetUser(user);
|
||||
Context.SetOrganization(organization);
|
||||
|
||||
var identity = new ClaimsIdentity(
|
||||
[
|
||||
new Claim(ClaimTypes.NameIdentifier, user.Id),
|
||||
new Claim(ClaimTypes.Name, user.DisplayName),
|
||||
new Claim(ClaimTypes.Email, user.Email),
|
||||
new Claim(ClaimTypes.Role, user.Role),
|
||||
new Claim(ClaimNames.OrganizationId, organization.Id),
|
||||
new Claim(ClaimNames.OrganizationName, organization.Name),
|
||||
new Claim(ClaimNames.OrganizationSlug, organization.Slug),
|
||||
],
|
||||
Scheme.Name
|
||||
);
|
||||
|
||||
return AuthenticateResult.Success(new AuthenticationTicket(new ClaimsPrincipal(identity), Scheme.Name));
|
||||
}
|
||||
}
|
||||
@@ -6,4 +6,4 @@ internal static class ContextKeys
|
||||
internal const string SessionToken = nameof(SessionToken);
|
||||
internal const string User = nameof(User);
|
||||
internal const string Organization = nameof(Organization);
|
||||
}
|
||||
}
|
||||
+19
-5
@@ -29,9 +29,16 @@ internal static class HttpContextExtensions
|
||||
context.Items[ContextKeys.User] = user;
|
||||
}
|
||||
|
||||
internal static User? GetUser(this HttpContext context)
|
||||
internal static User GetUser(this HttpContext context)
|
||||
{
|
||||
return context.Items[ContextKeys.User] as User;
|
||||
return context.Items[ContextKeys.User] as User
|
||||
?? throw new NotAuthenticatedException("The authenticated user is not available for this request.");
|
||||
}
|
||||
|
||||
internal static bool TryGetUser(this HttpContext context, out User? user)
|
||||
{
|
||||
user = context.Items[ContextKeys.User] as User;
|
||||
return user is not null;
|
||||
}
|
||||
|
||||
internal static void SetOrganization(this HttpContext context, Organization? org)
|
||||
@@ -39,8 +46,15 @@ internal static class HttpContextExtensions
|
||||
context.Items[ContextKeys.Organization] = org;
|
||||
}
|
||||
|
||||
internal static Organization? GetOrganization(this HttpContext context)
|
||||
internal static Organization GetOrganization(this HttpContext context)
|
||||
{
|
||||
return context.Items[ContextKeys.Organization] as Organization;
|
||||
return context.Items[ContextKeys.Organization] as Organization
|
||||
?? throw new NotAuthenticatedException("The authenticated organization is not available for this request.");
|
||||
}
|
||||
}
|
||||
|
||||
internal static bool TryGetOrganization(this HttpContext context, out Organization? organization)
|
||||
{
|
||||
organization = context.Items[ContextKeys.Organization] as Organization;
|
||||
return organization is not null;
|
||||
}
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
namespace ParagonPlayground.Api.Context;
|
||||
|
||||
internal sealed class NotAuthenticatedException : Exception
|
||||
{
|
||||
public NotAuthenticatedException()
|
||||
{
|
||||
}
|
||||
|
||||
public NotAuthenticatedException(string message) : base(message)
|
||||
{
|
||||
}
|
||||
|
||||
public NotAuthenticatedException(string message, Exception innerException) : base(message, innerException)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -16,8 +16,8 @@ internal static class AuthEndpoints
|
||||
internal static RouteGroupBuilder MapAuthEndpoints(this RouteGroupBuilder group)
|
||||
{
|
||||
_ = group.MapPost("/login", LoginAsync);
|
||||
_ = group.MapPost("/logout", LogoutAsync);
|
||||
_ = group.MapGet("/me", Me);
|
||||
_ = group.MapPost("/logout", LogoutAsync).RequireAuthorization();
|
||||
_ = group.MapGet("/me", Me).RequireAuthorization();
|
||||
return group;
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ internal static class AuthEndpoints
|
||||
|
||||
if (user is null || passwordService.Verify(request.Password, user.PasswordHash) is false)
|
||||
{
|
||||
return Results.Json(new { error = "Invalid email or password" }, statusCode: StatusCodes.Status401Unauthorized);
|
||||
return Results.Problem(detail: "Invalid email or password", statusCode: StatusCodes.Status401Unauthorized);
|
||||
}
|
||||
|
||||
var org = await orgRepo.GetByIdAsync(user.OrganizationId, ct);
|
||||
@@ -65,6 +65,7 @@ internal static class AuthEndpoints
|
||||
Id = user.Id,
|
||||
Email = user.Email,
|
||||
DisplayName = user.DisplayName,
|
||||
Role = user.Role,
|
||||
OrganizationId = org?.Id ?? string.Empty,
|
||||
OrganizationName = org?.Name ?? string.Empty,
|
||||
OrganizationSlug = org?.Slug ?? string.Empty,
|
||||
@@ -88,18 +89,13 @@ internal static class AuthEndpoints
|
||||
cookieService.ClearSessionCookie(context);
|
||||
cookieService.ClearXsrfCookie(context);
|
||||
|
||||
return Results.Ok(new { message = "Logged out" });
|
||||
return Results.NoContent();
|
||||
}
|
||||
|
||||
private static IResult Me(HttpContext context)
|
||||
{
|
||||
var user = context.GetUser();
|
||||
|
||||
if (user is null)
|
||||
{
|
||||
return Results.Json(new { error = "Not authenticated" }, statusCode: StatusCodes.Status401Unauthorized);
|
||||
}
|
||||
|
||||
var org = context.GetOrganization();
|
||||
|
||||
return Results.Ok(new UserResponse
|
||||
@@ -107,6 +103,7 @@ internal static class AuthEndpoints
|
||||
Id = user.Id,
|
||||
Email = user.Email,
|
||||
DisplayName = user.DisplayName,
|
||||
Role = user.Role,
|
||||
OrganizationId = org?.Id ?? string.Empty,
|
||||
OrganizationName = org?.Name ?? string.Empty,
|
||||
OrganizationSlug = org?.Slug ?? string.Empty,
|
||||
|
||||
+214
@@ -0,0 +1,214 @@
|
||||
using MongoDB.Bson;
|
||||
|
||||
using ParagonPlayground.Api.Auth;
|
||||
using ParagonPlayground.Api.Context;
|
||||
using ParagonPlayground.Api.Services;
|
||||
using ParagonPlayground.Domain.DTOs;
|
||||
using ParagonPlayground.Domain.Entities;
|
||||
using ParagonPlayground.Infrastructure.Data;
|
||||
|
||||
namespace ParagonPlayground.Api.Endpoints;
|
||||
|
||||
internal static class IntegrationEndpoints
|
||||
{
|
||||
internal static RouteGroupBuilder MapIntegrationEndpoints(this RouteGroupBuilder group)
|
||||
{
|
||||
_ = group.MapGet("/config", GetConfig);
|
||||
_ = group.MapPut("/config", PutConfig).RequireAdmin();
|
||||
_ = group.MapGet("/credentials", GetCredentials);
|
||||
_ = group.MapGet("/credentials/org", GetOrgCredentials).RequireAdmin();
|
||||
_ = group.MapPost("/credentials", PostCredential);
|
||||
_ = group.MapDelete("/credentials/org", PurgeOrgCredentials).RequireAdmin();
|
||||
_ = group.MapDelete("/credentials/{credentialId}", DeleteCredential);
|
||||
_ = group.RequireAuthorization();
|
||||
return group;
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetConfig(
|
||||
HttpContext context,
|
||||
OrganizationIntegrationRepository repo,
|
||||
CancellationToken ct
|
||||
)
|
||||
{
|
||||
var org = context.GetOrganization();
|
||||
|
||||
var config = await repo.GetByOrganizationIdAsync(org.Id, ct);
|
||||
|
||||
if (config is null)
|
||||
{
|
||||
return Results.Problem(detail: "Integration configuration not found", statusCode: StatusCodes.Status404NotFound);
|
||||
}
|
||||
|
||||
return Results.Ok(new IntegrationConfigResponse
|
||||
{
|
||||
Id = config.Id,
|
||||
OrganizationId = config.OrganizationId,
|
||||
ConnectionMode = config.ConnectionMode,
|
||||
SharePointSiteUrl = config.SharePointSiteUrl,
|
||||
SharePointSiteId = config.SharePointSiteId,
|
||||
SharePointFolderPath = config.SharePointFolderPath,
|
||||
UpdatedAt = config.UpdatedAt,
|
||||
});
|
||||
}
|
||||
|
||||
private static async Task<IResult> PutConfig(
|
||||
IntegrationConfigRequest request,
|
||||
HttpContext context,
|
||||
OrganizationIntegrationRepository repo,
|
||||
UserCredentialRepository credRepo,
|
||||
ParagonService paragon,
|
||||
CancellationToken ct
|
||||
)
|
||||
{
|
||||
var user = context.GetUser();
|
||||
var org = context.GetOrganization();
|
||||
|
||||
var config = await repo.GetByOrganizationIdAsync(org.Id, ct);
|
||||
|
||||
config ??= new OrganizationIntegration
|
||||
{
|
||||
Id = ObjectId.GenerateNewId().ToString(),
|
||||
OrganizationId = org.Id,
|
||||
};
|
||||
|
||||
config.ConnectionMode = request.ConnectionMode;
|
||||
config.SharePointFolderPath = request.SharePointFolderPath?.Trim();
|
||||
config.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.SharePointSiteUrl) is false)
|
||||
{
|
||||
config.SharePointSiteUrl = request.SharePointSiteUrl.Trim();
|
||||
|
||||
var credentials = await credRepo.GetByUserIdAsync(user.Id, ct);
|
||||
var spCredential = credentials.FirstOrDefault(c =>
|
||||
c.IntegrationType.Equals("sharepoint", StringComparison.OrdinalIgnoreCase)
|
||||
);
|
||||
|
||||
if (spCredential is not null && paragon.IsConfigured)
|
||||
{
|
||||
var jwt = paragon.GenerateToken(org.Id, spCredential.CredentialId);
|
||||
|
||||
config.SharePointSiteId = await paragon.ResolveSiteUrlAsync(
|
||||
jwt,
|
||||
spCredential.CredentialId,
|
||||
config.SharePointSiteUrl,
|
||||
ct
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await repo.UpsertAsync(config, ct);
|
||||
|
||||
return Results.Ok(new IntegrationConfigResponse
|
||||
{
|
||||
Id = config.Id,
|
||||
OrganizationId = config.OrganizationId,
|
||||
ConnectionMode = config.ConnectionMode,
|
||||
SharePointSiteUrl = config.SharePointSiteUrl,
|
||||
SharePointSiteId = config.SharePointSiteId,
|
||||
SharePointFolderPath = config.SharePointFolderPath,
|
||||
UpdatedAt = config.UpdatedAt,
|
||||
});
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetCredentials(
|
||||
HttpContext context,
|
||||
UserCredentialRepository repo,
|
||||
CancellationToken ct
|
||||
)
|
||||
{
|
||||
var user = context.GetUser();
|
||||
|
||||
var credentials = await repo.GetByUserIdAsync(user.Id, ct);
|
||||
|
||||
return Results.Ok(credentials.Select(c => new CredentialResponse
|
||||
{
|
||||
Id = c.Id,
|
||||
CredentialId = c.CredentialId,
|
||||
IntegrationType = c.IntegrationType,
|
||||
ConnectedAt = c.ConnectedAt,
|
||||
}));
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetOrgCredentials(
|
||||
HttpContext context,
|
||||
UserCredentialRepository repo,
|
||||
CancellationToken ct
|
||||
)
|
||||
{
|
||||
var org = context.GetOrganization();
|
||||
|
||||
var credentials = await repo.GetByOrganizationIdAsync(org.Id, ct);
|
||||
|
||||
return Results.Ok(credentials.Select(c => new CredentialResponse
|
||||
{
|
||||
Id = c.Id,
|
||||
CredentialId = c.CredentialId,
|
||||
IntegrationType = c.IntegrationType,
|
||||
ConnectedAt = c.ConnectedAt,
|
||||
}));
|
||||
}
|
||||
|
||||
private static async Task<IResult> PostCredential(
|
||||
CredentialRequest request,
|
||||
HttpContext context,
|
||||
UserCredentialRepository repo,
|
||||
CancellationToken ct
|
||||
)
|
||||
{
|
||||
var user = context.GetUser();
|
||||
var org = context.GetOrganization();
|
||||
|
||||
var credential = new UserCredential
|
||||
{
|
||||
Id = ObjectId.GenerateNewId().ToString(),
|
||||
UserId = user.Id,
|
||||
OrganizationId = org.Id,
|
||||
CredentialId = request.CredentialId,
|
||||
IntegrationType = request.IntegrationType,
|
||||
ConnectedAt = DateTime.UtcNow,
|
||||
};
|
||||
|
||||
await repo.CreateAsync(credential, ct);
|
||||
|
||||
return Results.Created($"/api/integration/credentials/{credential.Id}", new CredentialResponse
|
||||
{
|
||||
Id = credential.Id,
|
||||
CredentialId = credential.CredentialId,
|
||||
IntegrationType = credential.IntegrationType,
|
||||
ConnectedAt = credential.ConnectedAt,
|
||||
});
|
||||
}
|
||||
|
||||
private static async Task<IResult> PurgeOrgCredentials(
|
||||
HttpContext context,
|
||||
UserCredentialRepository repo,
|
||||
CancellationToken ct
|
||||
)
|
||||
{
|
||||
var org = context.GetOrganization();
|
||||
|
||||
_ = await repo.DeleteByOrganizationIdAsync(org.Id, ct);
|
||||
|
||||
return Results.NoContent();
|
||||
}
|
||||
|
||||
private static async Task<IResult> DeleteCredential(
|
||||
string credentialId,
|
||||
HttpContext context,
|
||||
UserCredentialRepository repo,
|
||||
CancellationToken ct
|
||||
)
|
||||
{
|
||||
var user = context.GetUser();
|
||||
|
||||
var deleted = await repo.DeleteByCredentialIdAsync(credentialId, user.Id, ct);
|
||||
|
||||
if (deleted is false)
|
||||
{
|
||||
return Results.Problem(detail: "Credential not found", statusCode: StatusCodes.Status404NotFound);
|
||||
}
|
||||
|
||||
return Results.NoContent();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
using ParagonPlayground.Api.Context;
|
||||
using ParagonPlayground.Api.Services;
|
||||
using ParagonPlayground.Domain.DTOs;
|
||||
using ParagonPlayground.Infrastructure.Data;
|
||||
|
||||
namespace ParagonPlayground.Api.Endpoints;
|
||||
|
||||
internal static class ParagonEndpoints
|
||||
{
|
||||
internal static RouteGroupBuilder MapParagonEndpoints(this RouteGroupBuilder group)
|
||||
{
|
||||
_ = group.MapGet("/token", GenerateToken);
|
||||
_ = group.RequireAuthorization();
|
||||
return group;
|
||||
}
|
||||
|
||||
private static async Task<IResult> GenerateToken(
|
||||
HttpContext context,
|
||||
ParagonService paragon,
|
||||
UserCredentialRepository credRepo,
|
||||
CancellationToken ct
|
||||
)
|
||||
{
|
||||
var user = context.GetUser();
|
||||
var org = context.GetOrganization();
|
||||
|
||||
if (paragon.IsConfigured is false)
|
||||
{
|
||||
return Results.Problem(
|
||||
detail: "Paragon integration is not configured. Set Paragon:ProjectId and Paragon:SigningKey.",
|
||||
statusCode: StatusCodes.Status400BadRequest
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
return Results.Ok(new ParagonTokenResponse
|
||||
{
|
||||
ParagonJwt = jwt,
|
||||
ProjectId = paragon.ProjectId,
|
||||
});
|
||||
}
|
||||
}
|
||||
+329
@@ -0,0 +1,329 @@
|
||||
using System.Text.Json;
|
||||
|
||||
using MongoDB.Bson;
|
||||
|
||||
using ParagonPlayground.Api.Context;
|
||||
using ParagonPlayground.Api.Services;
|
||||
using ParagonPlayground.Domain.DTOs;
|
||||
using ParagonPlayground.Domain.Entities;
|
||||
using ParagonPlayground.Infrastructure.Data;
|
||||
|
||||
namespace ParagonPlayground.Api.Endpoints;
|
||||
|
||||
internal static class StorageEndpoints
|
||||
{
|
||||
internal static RouteGroupBuilder MapStorageEndpoints(this RouteGroupBuilder group)
|
||||
{
|
||||
_ = group.MapGet("/", ListItems);
|
||||
_ = group.MapPost("/folders", CreateFolder);
|
||||
_ = group.MapPost("/files", UploadFile);
|
||||
_ = group.MapDelete("/{id}", DeleteItem);
|
||||
_ = group.MapGet("/{id}/download", GetDownloadUrls);
|
||||
_ = group.MapGet("/{id}/content", ProxyContent);
|
||||
_ = group.RequireAuthorization();
|
||||
return group;
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListItems(
|
||||
string? parentId,
|
||||
HttpContext context,
|
||||
StorageItemRepository repo,
|
||||
UserRepository userRepo,
|
||||
CancellationToken ct
|
||||
)
|
||||
{
|
||||
var org = context.GetOrganization();
|
||||
|
||||
var items = await repo.GetByParentIdAsync(org.Id, parentId, ct);
|
||||
var userIds = items.Select(i => i.CreatedByUserId).Distinct().ToList();
|
||||
var users = new Dictionary<string, string>();
|
||||
|
||||
foreach (var uid in userIds)
|
||||
{
|
||||
var u = await userRepo.GetByIdAsync(uid, ct);
|
||||
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,
|
||||
}));
|
||||
}
|
||||
|
||||
private static async Task<IResult> CreateFolder(
|
||||
CreateFolderRequest request,
|
||||
HttpContext context,
|
||||
StorageItemRepository repo,
|
||||
CancellationToken ct
|
||||
)
|
||||
{
|
||||
var user = context.GetUser();
|
||||
var org = context.GetOrganization();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Name))
|
||||
{
|
||||
return Results.Problem(detail: "Folder name is required", statusCode: StatusCodes.Status400BadRequest);
|
||||
}
|
||||
|
||||
var item = new StorageItem
|
||||
{
|
||||
Id = ObjectId.GenerateNewId().ToString(),
|
||||
OrganizationId = org.Id,
|
||||
Name = request.Name.Trim(),
|
||||
IsFolder = true,
|
||||
ParentId = request.ParentId,
|
||||
CreatedByUserId = user.Id,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
};
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
private static async Task<IResult> UploadFile(
|
||||
HttpContext context,
|
||||
StorageItemRepository storageRepo,
|
||||
UserCredentialRepository credRepo,
|
||||
OrganizationIntegrationRepository configRepo,
|
||||
ParagonService paragon,
|
||||
UserRepository userRepo,
|
||||
CancellationToken ct
|
||||
)
|
||||
{
|
||||
var user = context.GetUser();
|
||||
var org = context.GetOrganization();
|
||||
|
||||
var file = context.Request.Form.Files.Count > 0 ? context.Request.Form.Files[0] : null;
|
||||
|
||||
if (file is null || file.Length is 0)
|
||||
{
|
||||
return Results.Problem(detail: "File is required", statusCode: StatusCodes.Status400BadRequest);
|
||||
}
|
||||
|
||||
var parentId = (string?)context.Request.Form["parentId"];
|
||||
|
||||
var config = await configRepo.GetByOrganizationIdAsync(org.Id, ct);
|
||||
|
||||
if (config is null || string.IsNullOrWhiteSpace(config.SharePointSiteId))
|
||||
{
|
||||
return Results.Problem(
|
||||
detail: "SharePoint integration is not configured. Ask an admin to set it up.",
|
||||
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. Connect to SharePoint first.",
|
||||
statusCode: StatusCodes.Status400BadRequest
|
||||
);
|
||||
}
|
||||
|
||||
if (paragon.IsConfigured is false)
|
||||
{
|
||||
return Results.Problem(
|
||||
detail: "Paragon integration is not configured on the server.",
|
||||
statusCode: StatusCodes.Status500InternalServerError
|
||||
);
|
||||
}
|
||||
|
||||
var jwt = paragon.GenerateToken(org.Id, spCredential.CredentialId);
|
||||
|
||||
string sharePointResponse;
|
||||
|
||||
await using (var stream = file.OpenReadStream())
|
||||
{
|
||||
sharePointResponse = await paragon.UploadFileAsync(
|
||||
jwt,
|
||||
spCredential.CredentialId,
|
||||
config.SharePointSiteId,
|
||||
config.SharePointFolderPath ?? "",
|
||||
file.FileName,
|
||||
stream,
|
||||
file.ContentType,
|
||||
ct
|
||||
);
|
||||
}
|
||||
|
||||
string? driveItemId = null;
|
||||
string? webUrl = null;
|
||||
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(sharePointResponse);
|
||||
|
||||
if (doc.RootElement.TryGetProperty("output", out var output))
|
||||
{
|
||||
driveItemId = output.TryGetProperty("id", out var id) ? id.GetString() : null;
|
||||
webUrl = output.TryGetProperty("webUrl", out var wu) ? wu.GetString() : null;
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Response parsing is best-effort; store what we have
|
||||
}
|
||||
|
||||
var storageItem = new StorageItem
|
||||
{
|
||||
Id = ObjectId.GenerateNewId().ToString(),
|
||||
OrganizationId = org.Id,
|
||||
Name = file.FileName,
|
||||
IsFolder = false,
|
||||
ParentId = parentId,
|
||||
ContentType = file.ContentType,
|
||||
FileSize = file.Length,
|
||||
SharePointSiteId = config.SharePointSiteId,
|
||||
SharePointDriveItemId = driveItemId,
|
||||
SharePointWebUrl = webUrl,
|
||||
CreatedByUserId = user.Id,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
};
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
private static async Task<IResult> DeleteItem(
|
||||
string id,
|
||||
HttpContext context,
|
||||
StorageItemRepository repo,
|
||||
CancellationToken ct
|
||||
)
|
||||
{
|
||||
var org = context.GetOrganization();
|
||||
|
||||
var item = await repo.GetByIdAsync(id, ct);
|
||||
|
||||
if (item is null || item.OrganizationId != org.Id)
|
||||
{
|
||||
return Results.Problem(detail: "Item not found", statusCode: StatusCodes.Status404NotFound);
|
||||
}
|
||||
|
||||
await repo.DeleteAsync(id, ct);
|
||||
|
||||
return Results.NoContent();
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetDownloadUrls(
|
||||
string id,
|
||||
HttpContext context,
|
||||
StorageItemRepository repo,
|
||||
CancellationToken ct
|
||||
)
|
||||
{
|
||||
var org = context.GetOrganization();
|
||||
|
||||
var item = await repo.GetByIdAsync(id, ct);
|
||||
|
||||
if (item is null || item.OrganizationId != org.Id || item.IsFolder)
|
||||
{
|
||||
return Results.Problem(detail: "Item not found", statusCode: StatusCodes.Status404NotFound);
|
||||
}
|
||||
|
||||
var baseUrl = $"{context.Request.Scheme}://{context.Request.Host}";
|
||||
|
||||
return Results.Ok(new DownloadResponse
|
||||
{
|
||||
SharePointUrl = item.SharePointWebUrl,
|
||||
ProxyUrl = $"{baseUrl}/api/storage/{item.Id}/content",
|
||||
});
|
||||
}
|
||||
|
||||
private static async Task<IResult> ProxyContent(
|
||||
string id,
|
||||
HttpContext context,
|
||||
StorageItemRepository storageRepo,
|
||||
UserCredentialRepository credRepo,
|
||||
ParagonService paragon,
|
||||
CancellationToken ct
|
||||
)
|
||||
{
|
||||
var user = context.GetUser();
|
||||
var org = context.GetOrganization();
|
||||
|
||||
var item = await storageRepo.GetByIdAsync(id, ct);
|
||||
|
||||
if (item is null || item.OrganizationId != org.Id || item.IsFolder)
|
||||
{
|
||||
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(
|
||||
detail: "Paragon integration is not configured.",
|
||||
statusCode: StatusCodes.Status500InternalServerError
|
||||
);
|
||||
}
|
||||
|
||||
var jwt = paragon.GenerateToken(org.Id, spCredential.CredentialId);
|
||||
var fileStream = await paragon.DownloadFileAsync(
|
||||
jwt,
|
||||
spCredential.CredentialId,
|
||||
item.SharePointSiteId,
|
||||
item.SharePointDriveItemId,
|
||||
ct
|
||||
);
|
||||
|
||||
return Results.Stream(fileStream, item.ContentType ?? "application/octet-stream", item.Name);
|
||||
}
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
using Microsoft.AspNetCore.Diagnostics;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
using ParagonPlayground.Api.Context;
|
||||
|
||||
namespace ParagonPlayground.Api.Middleware;
|
||||
|
||||
internal sealed class NotAuthenticatedExceptionHandler(ILogger<NotAuthenticatedExceptionHandler> logger) : IExceptionHandler
|
||||
{
|
||||
private static readonly Action<ILogger, string, Exception> LogUnauthorizedAccess =
|
||||
LoggerMessage.Define<string>(
|
||||
LogLevel.Warning,
|
||||
new EventId(1, "NotAuthenticatedException"),
|
||||
"Session state was accessed without an authenticated request. Path: {Path}"
|
||||
);
|
||||
|
||||
private readonly ILogger<NotAuthenticatedExceptionHandler> _logger = logger;
|
||||
|
||||
public ValueTask<bool> TryHandleAsync(
|
||||
HttpContext httpContext,
|
||||
Exception exception,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
if (exception is not NotAuthenticatedException)
|
||||
{
|
||||
return ValueTask.FromResult(false);
|
||||
}
|
||||
|
||||
LogUnauthorizedAccess(_logger, httpContext.Request.Path.ToString(), exception);
|
||||
|
||||
httpContext.Response.StatusCode = StatusCodes.Status401Unauthorized;
|
||||
|
||||
if (httpContext.RequestServices.GetService<IProblemDetailsService>() is { } problemDetailsService)
|
||||
{
|
||||
return problemDetailsService.TryWriteAsync(new ProblemDetailsContext
|
||||
{
|
||||
HttpContext = httpContext,
|
||||
ProblemDetails = new ProblemDetails
|
||||
{
|
||||
Status = StatusCodes.Status401Unauthorized,
|
||||
Title = "Unauthorized",
|
||||
Detail = exception.Message,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return ValueTask.FromResult(false);
|
||||
}
|
||||
}
|
||||
-52
@@ -1,52 +0,0 @@
|
||||
using ParagonPlayground.Api.Context;
|
||||
using ParagonPlayground.Api.Infrastructure;
|
||||
using ParagonPlayground.Infrastructure.Data;
|
||||
using ParagonPlayground.Infrastructure.Services;
|
||||
|
||||
namespace ParagonPlayground.Api.Middleware;
|
||||
|
||||
internal class SessionAuthMiddleware(RequestDelegate next)
|
||||
{
|
||||
private readonly RequestDelegate _next = next;
|
||||
|
||||
public async Task InvokeAsync(
|
||||
HttpContext context,
|
||||
SessionRepository sessionRepo,
|
||||
UserRepository userRepo,
|
||||
OrganizationRepository orgRepo,
|
||||
CookieService cookieService
|
||||
)
|
||||
{
|
||||
var ct = context.RequestAborted;
|
||||
var sessionToken = cookieService.GetSessionToken(context);
|
||||
|
||||
if (string.IsNullOrEmpty(sessionToken) is false)
|
||||
{
|
||||
var tokenHash = TokenHelper.HashToken(sessionToken);
|
||||
var session = await sessionRepo.GetByTokenHashAsync(tokenHash, ct);
|
||||
|
||||
if (session is not null && session.ExpiresAt > DateTime.UtcNow)
|
||||
{
|
||||
context.SetSession(session);
|
||||
context.SetSessionToken(sessionToken);
|
||||
|
||||
var user = await userRepo.GetByIdAsync(session.UserId, ct);
|
||||
|
||||
if (user is not null)
|
||||
{
|
||||
context.SetUser(user);
|
||||
|
||||
var org = await orgRepo.GetByIdAsync(user.OrganizationId, ct);
|
||||
|
||||
if (org is not null)
|
||||
{
|
||||
context.SetOrganization(org);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await _next(context);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace ParagonPlayground.Api.Options;
|
||||
|
||||
internal class ParagonOptions
|
||||
{
|
||||
public const string SectionName = "Paragon";
|
||||
|
||||
public string ProjectId { get; set; } = string.Empty;
|
||||
|
||||
public string SigningKey { get; set; } = string.Empty;
|
||||
|
||||
public string ProxyBaseUrl { get; set; } = "https://proxy.useparagon.com";
|
||||
}
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MongoDB.Driver" />
|
||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http.Resilience" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.HttpOverrides;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
using ParagonPlayground.Api.Auth;
|
||||
using ParagonPlayground.Api.Endpoints;
|
||||
using ParagonPlayground.Api.Middleware;
|
||||
using ParagonPlayground.Api.Options;
|
||||
using ParagonPlayground.Api.Services;
|
||||
using ParagonPlayground.Infrastructure.Data;
|
||||
using ParagonPlayground.Infrastructure.Services;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.Services.Configure<MongoDbOptions>(builder.Configuration.GetSection(MongoDbOptions.SectionName));
|
||||
|
||||
builder.Services.AddSingleton(static sp =>
|
||||
{
|
||||
var opts = sp.GetRequiredService<Microsoft.Extensions.Options.IOptions<MongoDbOptions>>().Value;
|
||||
var opts = sp.GetRequiredService<IOptions<MongoDbOptions>>().Value;
|
||||
return new MongoDbContext(opts.ConnectionString, opts.DatabaseName);
|
||||
});
|
||||
|
||||
@@ -20,6 +25,29 @@ builder.Services.AddSingleton<CookieService>();
|
||||
builder.Services.AddSingleton<OrganizationRepository>();
|
||||
builder.Services.AddSingleton<UserRepository>();
|
||||
builder.Services.AddSingleton<SessionRepository>();
|
||||
builder.Services.AddSingleton<StorageItemRepository>();
|
||||
builder.Services.AddSingleton<UserCredentialRepository>();
|
||||
builder.Services.AddSingleton<OrganizationIntegrationRepository>();
|
||||
builder.Services.AddSingleton<ParagonService>();
|
||||
|
||||
builder.Services.Configure<ParagonOptions>(builder.Configuration.GetSection(ParagonOptions.SectionName));
|
||||
|
||||
builder.Services.AddHttpClient<ParagonApiClient>()
|
||||
.AddStandardResilienceHandler();
|
||||
|
||||
builder.Services.AddProblemDetails();
|
||||
builder.Services.AddExceptionHandler<NotAuthenticatedExceptionHandler>();
|
||||
|
||||
builder.Services
|
||||
.AddAuthentication(SessionAuthDefaults.Scheme)
|
||||
.AddScheme<AuthenticationSchemeOptions, SessionAuthenticationHandler>(
|
||||
SessionAuthDefaults.Scheme,
|
||||
_ => { }
|
||||
);
|
||||
|
||||
builder.Services
|
||||
.AddAuthorizationBuilder()
|
||||
.AddPolicy(PolicyNames.AdminOnly, policy => policy.RequireRole(RoleNames.Admin));
|
||||
|
||||
builder.Services.Configure<ForwardedHeadersOptions>(static options =>
|
||||
{
|
||||
@@ -29,8 +57,14 @@ builder.Services.Configure<ForwardedHeadersOptions>(static options =>
|
||||
var app = builder.Build();
|
||||
|
||||
app.UseForwardedHeaders();
|
||||
app.UseExceptionHandler();
|
||||
app.UseStatusCodePages();
|
||||
app.UseMiddleware<TenantResolutionMiddleware>();
|
||||
app.UseMiddleware<SessionAuthMiddleware>();
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
app.MapGroup("/api/auth").MapAuthEndpoints();
|
||||
app.MapGroup("/api/paragon").MapParagonEndpoints();
|
||||
app.MapGroup("/api/integration").MapIntegrationEndpoints();
|
||||
app.MapGroup("/api/storage").MapStorageEndpoints();
|
||||
|
||||
app.Run();
|
||||
@@ -0,0 +1,107 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
using ParagonPlayground.Api.Options;
|
||||
|
||||
namespace ParagonPlayground.Api.Services;
|
||||
|
||||
internal sealed class ParagonApiClient
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly string _projectId;
|
||||
|
||||
public ParagonApiClient(HttpClient httpClient, IOptions<ParagonOptions> options)
|
||||
{
|
||||
_httpClient = httpClient;
|
||||
_projectId = options.Value.ProjectId;
|
||||
_httpClient.BaseAddress = new Uri(options.Value.ProxyBaseUrl.TrimEnd('/') + "/");
|
||||
}
|
||||
|
||||
public async Task<string> UploadFileAsync(
|
||||
string jwt,
|
||||
string credentialId,
|
||||
string siteId,
|
||||
string folderPath,
|
||||
string fileName,
|
||||
Stream fileStream,
|
||||
string contentType,
|
||||
CancellationToken ct
|
||||
)
|
||||
{
|
||||
var url = $"projects/{_projectId}/sdk/proxy/sharepoint"
|
||||
+ $"/sites/{siteId}/drive/root:/{folderPath.Trim('/')}/{fileName}:/content";
|
||||
|
||||
using var ms = new MemoryStream();
|
||||
await fileStream.CopyToAsync(ms, ct).ConfigureAwait(false);
|
||||
ms.Position = 0;
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Put, url)
|
||||
{
|
||||
Content = new StreamContent(ms),
|
||||
};
|
||||
|
||||
_ = request.Headers.TryAddWithoutValidation("Authorization", $"Bearer {jwt}");
|
||||
_ = request.Headers.TryAddWithoutValidation("X-Paragon-Credential", credentialId);
|
||||
request.Content.Headers.ContentType = new MediaTypeHeaderValue(contentType);
|
||||
|
||||
using var response = await _httpClient.SendAsync(request, ct).ConfigureAwait(false);
|
||||
_ = response.EnsureSuccessStatusCode();
|
||||
|
||||
return await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<Stream> DownloadFileAsync(
|
||||
string jwt,
|
||||
string credentialId,
|
||||
string siteId,
|
||||
string driveItemId,
|
||||
CancellationToken ct
|
||||
)
|
||||
{
|
||||
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);
|
||||
|
||||
var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false);
|
||||
_ = response.EnsureSuccessStatusCode();
|
||||
|
||||
return await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<string> ResolveSiteUrlAsync(
|
||||
string jwt,
|
||||
string credentialId,
|
||||
string siteUrl,
|
||||
CancellationToken ct
|
||||
)
|
||||
{
|
||||
var uri = new Uri(siteUrl);
|
||||
var segments = uri.AbsolutePath.TrimEnd('/')
|
||||
.Split('/', StringSplitOptions.RemoveEmptyEntries);
|
||||
var encodedPath = segments.Length > 0
|
||||
? ":/" + string.Join("/", segments.Select(Uri.EscapeDataString))
|
||||
: "";
|
||||
|
||||
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}");
|
||||
_ = 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);
|
||||
|
||||
var output = doc.RootElement.GetProperty("output");
|
||||
var idProp = output.GetProperty("id");
|
||||
return idProp.GetString() ?? "";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
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<ParagonOptions> 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<string, object>
|
||||
{
|
||||
["integration:sharepoint"] = new Dictionary<string, object>
|
||||
{
|
||||
[$"credential:{credentialId}"] = true,
|
||||
},
|
||||
}
|
||||
: (object)new Dictionary<string, object>
|
||||
{
|
||||
["integration:sharepoint"] = new Dictionary<string, object>
|
||||
{
|
||||
["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<string> 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<string> ResolveSiteUrlAsync(
|
||||
string jwt,
|
||||
string credentialId,
|
||||
string siteUrl,
|
||||
CancellationToken ct
|
||||
)
|
||||
{
|
||||
return _apiClient.ResolveSiteUrlAsync(jwt, credentialId, siteUrl, ct);
|
||||
}
|
||||
|
||||
public Task<Stream> DownloadFileAsync(
|
||||
string jwt,
|
||||
string credentialId,
|
||||
string siteId,
|
||||
string driveItemId,
|
||||
CancellationToken ct
|
||||
)
|
||||
{
|
||||
return _apiClient.DownloadFileAsync(jwt, credentialId, siteId, driveItemId, ct);
|
||||
}
|
||||
}
|
||||
+7
-1
@@ -14,7 +14,8 @@ namespace ParagonPlayground.Cli.Commands;
|
||||
internal class ProvisionUserCommand(
|
||||
OrganizationRepository orgRepo,
|
||||
UserRepository userRepo,
|
||||
PasswordService passwordService) : AsyncCommand<ProvisionUserCommand.Settings>
|
||||
PasswordService passwordService
|
||||
) : AsyncCommand<ProvisionUserCommand.Settings>
|
||||
{
|
||||
internal class Settings : CommandSettings
|
||||
{
|
||||
@@ -33,6 +34,10 @@ internal class ProvisionUserCommand(
|
||||
[Description("Organization slug")]
|
||||
[CommandOption("-o|--org-slug")]
|
||||
public required string OrgSlug { get; set; }
|
||||
|
||||
[Description("User role (admin or member)")]
|
||||
[CommandOption("-r|--role")]
|
||||
public string Role { get; set; } = "member";
|
||||
}
|
||||
|
||||
protected override async Task<int> ExecuteAsync(CommandContext context, Settings settings, CancellationToken cancellationToken)
|
||||
@@ -60,6 +65,7 @@ internal class ProvisionUserCommand(
|
||||
DisplayName = settings.Name,
|
||||
PasswordHash = passwordService.Hash(settings.Password),
|
||||
OrganizationId = org.Id,
|
||||
Role = settings.Role,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
};
|
||||
|
||||
|
||||
@@ -41,11 +41,11 @@ internal class SeedCommand(
|
||||
|
||||
var users = new[]
|
||||
{
|
||||
(Email: "alice@acme.com", Name: "Alice", Password: "password123"),
|
||||
(Email: "bob@acme.com", Name: "Bob", Password: "password123"),
|
||||
(Email: "alice@acme.com", Name: "Alice", Password: "password123", Role: "admin"),
|
||||
(Email: "bob@acme.com", Name: "Bob", Password: "password123", Role: "member"),
|
||||
};
|
||||
|
||||
foreach (var (email, name, password) in users)
|
||||
foreach (var (email, name, password, role) in users)
|
||||
{
|
||||
var existing = await userRepo.GetByEmailAsync(email, cancellationToken);
|
||||
|
||||
@@ -58,6 +58,7 @@ internal class SeedCommand(
|
||||
DisplayName = name,
|
||||
PasswordHash = passwordService.Hash(password),
|
||||
OrganizationId = org.Id,
|
||||
Role = role,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
};
|
||||
|
||||
@@ -67,7 +68,16 @@ internal class SeedCommand(
|
||||
}
|
||||
else
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[yellow]User '{email}' already exists[/]");
|
||||
if (existing.Role != role)
|
||||
{
|
||||
existing.Role = role;
|
||||
await userRepo.ReplaceAsync(existing, cancellationToken);
|
||||
AnsiConsole.MarkupLine($"[yellow]User '{email}' role updated to '{role}'[/]");
|
||||
}
|
||||
else
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[yellow]User '{email}' already exists[/]");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
[*.{cs,vb}]
|
||||
dotnet_diagnostic.CA1056.severity = none
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
namespace ParagonPlayground.Domain.DTOs;
|
||||
|
||||
/// <summary>Request to create a new virtual folder.</summary>
|
||||
public class CreateFolderRequest
|
||||
{
|
||||
/// <summary>Folder name.</summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Optional parent folder ID (null for root).</summary>
|
||||
public string? ParentId { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace ParagonPlayground.Domain.DTOs;
|
||||
|
||||
/// <summary>Request to store a new Paragon credential mapping.</summary>
|
||||
public class CredentialRequest
|
||||
{
|
||||
/// <summary>Paragon credential ID from the integration install flow.</summary>
|
||||
public string CredentialId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Integration type (e.g. "sharepoint").</summary>
|
||||
public string IntegrationType { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace ParagonPlayground.Domain.DTOs;
|
||||
|
||||
/// <summary>Stored Paragon credential for the current user.</summary>
|
||||
public class CredentialResponse
|
||||
{
|
||||
/// <summary>Unique identifier.</summary>
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Paragon credential ID.</summary>
|
||||
public string CredentialId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Integration type (e.g. "sharepoint").</summary>
|
||||
public string IntegrationType { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Timestamp when the credential was connected.</summary>
|
||||
public DateTime ConnectedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace ParagonPlayground.Domain.DTOs;
|
||||
|
||||
/// <summary>Download URLs for a stored file.</summary>
|
||||
public class DownloadResponse
|
||||
{
|
||||
/// <summary>Direct SharePoint web URL (opens in SharePoint).</summary>
|
||||
public string? SharePointUrl { get; set; }
|
||||
|
||||
/// <summary>App-proxied download URL (streams through the backend via Paragon).</summary>
|
||||
public string? ProxyUrl { get; set; }
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
namespace ParagonPlayground.Domain.DTOs;
|
||||
|
||||
/// <summary>Request to create or update the organization's integration configuration.</summary>
|
||||
public class IntegrationConfigRequest
|
||||
{
|
||||
/// <summary>Connection mode: "default" (ISV-provided app) or "byo" (user-configured OAuth).</summary>
|
||||
public string ConnectionMode { get; set; } = "default";
|
||||
|
||||
/// <summary>Target SharePoint site URL (e.g. https://contoso.sharepoint.com/sites/MySite).</summary>
|
||||
public string? SharePointSiteUrl { get; set; }
|
||||
|
||||
/// <summary>Target folder path within the SharePoint site.</summary>
|
||||
public string? SharePointFolderPath { get; set; }
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
namespace ParagonPlayground.Domain.DTOs;
|
||||
|
||||
/// <summary>Organization's Paragon/SharePoint integration configuration.</summary>
|
||||
public class IntegrationConfigResponse
|
||||
{
|
||||
/// <summary>Unique identifier.</summary>
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Organization this config belongs to.</summary>
|
||||
public string OrganizationId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Connection mode: "default" (ISV-provided app) or "byo" (user-configured OAuth).</summary>
|
||||
public string ConnectionMode { get; set; } = "default";
|
||||
|
||||
/// <summary>Full SharePoint site URL (e.g. https://contoso.sharepoint.com/sites/MySite).</summary>
|
||||
public string? SharePointSiteUrl { get; set; }
|
||||
|
||||
/// <summary>Resolved SharePoint site ID (e.g. contoso.sharepoint.com,guid,guid).</summary>
|
||||
public string? SharePointSiteId { get; set; }
|
||||
|
||||
/// <summary>Target folder path within SharePoint.</summary>
|
||||
public string? SharePointFolderPath { get; set; }
|
||||
|
||||
/// <summary>Timestamp of last update.</summary>
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
namespace ParagonPlayground.Domain.DTOs;
|
||||
|
||||
/// <summary>Response containing a signed Paragon JWT and project ID.</summary>
|
||||
public class ParagonTokenResponse
|
||||
{
|
||||
/// <summary>Signed JWT for authenticating with the Paragon SDK.</summary>
|
||||
public string ParagonJwt { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Paragon project ID for SDK initialization.</summary>
|
||||
public string ProjectId { get; set; } = string.Empty;
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
namespace ParagonPlayground.Domain.DTOs;
|
||||
|
||||
/// <summary>File or folder returned by the storage API.</summary>
|
||||
public class StorageItemResponse
|
||||
{
|
||||
/// <summary>Unique identifier.</summary>
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Display name.</summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>True for folders, false for files.</summary>
|
||||
public bool IsFolder { get; set; }
|
||||
|
||||
/// <summary>Parent folder ID (null for root items).</summary>
|
||||
public string? ParentId { get; set; }
|
||||
|
||||
/// <summary>MIME type (null for folders).</summary>
|
||||
public string? ContentType { get; set; }
|
||||
|
||||
/// <summary>File size in bytes (0 for folders).</summary>
|
||||
public long FileSize { get; set; }
|
||||
|
||||
/// <summary>Direct SharePoint web URL (null for folders).</summary>
|
||||
public string? SharePointWebUrl { get; set; }
|
||||
|
||||
/// <summary>User who created this item.</summary>
|
||||
public string CreatedByUserId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Display name of the creator.</summary>
|
||||
public string CreatedByDisplayName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Timestamp when the item was created.</summary>
|
||||
public DateTime CreatedAt { get; set; }
|
||||
}
|
||||
@@ -20,4 +20,7 @@ public class UserResponse
|
||||
|
||||
/// <summary>URL-friendly slug of the user's organization.</summary>
|
||||
public string OrganizationSlug { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>User's role within the organization ("admin" or "member").</summary>
|
||||
public string Role { get; set; } = "member";
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
namespace ParagonPlayground.Domain.Entities;
|
||||
|
||||
/// <summary>Per-organization Paragon/SharePoint integration configuration.</summary>
|
||||
public class OrganizationIntegration
|
||||
{
|
||||
/// <summary>Unique identifier (MongoDB ObjectId).</summary>
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Organization this config belongs to.</summary>
|
||||
public string OrganizationId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Connection mode: "default" (ISV-provided app) or "byo" (user-configured OAuth).</summary>
|
||||
public string ConnectionMode { get; set; } = "default";
|
||||
|
||||
/// <summary>Full SharePoint site URL (e.g. https://contoso.sharepoint.com/sites/MySite).</summary>
|
||||
public string? SharePointSiteUrl { get; set; }
|
||||
|
||||
/// <summary>Resolved SharePoint site ID (e.g. contoso.sharepoint.com,guid,guid).</summary>
|
||||
public string? SharePointSiteId { get; set; }
|
||||
|
||||
/// <summary>Target folder path within the SharePoint site.</summary>
|
||||
public string? SharePointFolderPath { get; set; }
|
||||
|
||||
/// <summary>Timestamp of the last configuration update.</summary>
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
namespace ParagonPlayground.Domain.Entities;
|
||||
|
||||
/// <summary>Represents a file or folder in the virtual storage tree (independent of SharePoint structure).</summary>
|
||||
public class StorageItem
|
||||
{
|
||||
/// <summary>Unique identifier (MongoDB ObjectId).</summary>
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Organization this item belongs to.</summary>
|
||||
public string OrganizationId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Display name of the file or folder.</summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>True if this is a folder, false if it's a file.</summary>
|
||||
public bool IsFolder { get; set; }
|
||||
|
||||
/// <summary>Parent folder ID (null for root-level items).</summary>
|
||||
public string? ParentId { get; set; }
|
||||
|
||||
/// <summary>MIME type of the file (null for folders).</summary>
|
||||
public string? ContentType { get; set; }
|
||||
|
||||
/// <summary>File size in bytes (0 for folders).</summary>
|
||||
public long FileSize { get; set; }
|
||||
|
||||
/// <summary>SharePoint site ID where the file was uploaded (null for folders).</summary>
|
||||
public string? SharePointSiteId { get; set; }
|
||||
|
||||
/// <summary>SharePoint drive item ID (null for folders).</summary>
|
||||
public string? SharePointDriveItemId { get; set; }
|
||||
|
||||
/// <summary>SharePoint web URL for direct access (null for folders).</summary>
|
||||
public string? SharePointWebUrl { get; set; }
|
||||
|
||||
/// <summary>User who created this item.</summary>
|
||||
public string CreatedByUserId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Timestamp when the item was created.</summary>
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
@@ -18,6 +18,9 @@ public class User
|
||||
/// <summary>Identifier of the organization this user belongs to.</summary>
|
||||
public string OrganizationId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Role within the organization: "admin" or "member".</summary>
|
||||
public string Role { get; set; } = "member";
|
||||
|
||||
/// <summary>Timestamp when the user was created.</summary>
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
namespace ParagonPlayground.Domain.Entities;
|
||||
|
||||
/// <summary>Maps an app user to their Paragon integration credential.</summary>
|
||||
public class UserCredential
|
||||
{
|
||||
/// <summary>Unique identifier (MongoDB ObjectId).</summary>
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>App user who owns this credential.</summary>
|
||||
public string UserId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Organization the user belongs to.</summary>
|
||||
public string OrganizationId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Paragon credential ID from the integration install flow.</summary>
|
||||
public string CredentialId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Integration type (e.g. "sharepoint").</summary>
|
||||
public string IntegrationType { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Timestamp when the credential was connected.</summary>
|
||||
public DateTime ConnectedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
+12
@@ -22,6 +22,18 @@ public sealed class MongoDbContext : IDisposable
|
||||
public IMongoCollection<Session> Sessions =>
|
||||
_database.GetCollection<Session>("Sessions");
|
||||
|
||||
/// <summary>Storage items collection.</summary>
|
||||
public IMongoCollection<StorageItem> StorageItems =>
|
||||
_database.GetCollection<StorageItem>("StorageItems");
|
||||
|
||||
/// <summary>User credentials collection.</summary>
|
||||
public IMongoCollection<UserCredential> UserCredentials =>
|
||||
_database.GetCollection<UserCredential>("UserCredentials");
|
||||
|
||||
/// <summary>Organization integrations collection.</summary>
|
||||
public IMongoCollection<OrganizationIntegration> OrganizationIntegrations =>
|
||||
_database.GetCollection<OrganizationIntegration>("OrganizationIntegrations");
|
||||
|
||||
/// <summary>Initializes a new MongoDbContext and connects to the specified database.</summary>
|
||||
public MongoDbContext(string connectionString, string databaseName)
|
||||
{
|
||||
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
using MongoDB.Driver;
|
||||
|
||||
using ParagonPlayground.Domain.Entities;
|
||||
|
||||
namespace ParagonPlayground.Infrastructure.Data;
|
||||
|
||||
/// <summary>Repository for organization integration configuration data access.</summary>
|
||||
public class OrganizationIntegrationRepository(MongoDbContext context)
|
||||
{
|
||||
private readonly MongoDbContext _context = context;
|
||||
|
||||
/// <summary>Finds the integration config for an organization.</summary>
|
||||
public async Task<OrganizationIntegration?> GetByOrganizationIdAsync(
|
||||
string organizationId,
|
||||
CancellationToken ct
|
||||
)
|
||||
{
|
||||
return await _context.OrganizationIntegrations
|
||||
.Find(c => c.OrganizationId == organizationId)
|
||||
.FirstOrDefaultAsync(ct)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>Creates or replaces the integration config for an organization.</summary>
|
||||
public async Task UpsertAsync(OrganizationIntegration config, CancellationToken ct)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(config);
|
||||
|
||||
var filter = Builders<OrganizationIntegration>.Filter
|
||||
.Eq(c => c.OrganizationId, config.OrganizationId);
|
||||
|
||||
var options = new ReplaceOptions() { IsUpsert = true };
|
||||
|
||||
_ = await _context.OrganizationIntegrations
|
||||
.ReplaceOneAsync(filter, config, options, ct)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
+11
-3
@@ -12,18 +12,26 @@ public class OrganizationRepository(MongoDbContext context)
|
||||
/// <summary>Finds an organization by its URL slug.</summary>
|
||||
public async Task<Organization?> GetBySlugAsync(string slug, CancellationToken cancellationToken)
|
||||
{
|
||||
return await _context.Organizations.Find(o => o.Slug == slug).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
|
||||
return await _context.Organizations
|
||||
.Find(o => o.Slug == slug)
|
||||
.FirstOrDefaultAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>Finds an organization by its unique identifier.</summary>
|
||||
public async Task<Organization?> GetByIdAsync(string id, CancellationToken cancellationToken)
|
||||
{
|
||||
return await _context.Organizations.Find(o => o.Id == id).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
|
||||
return await _context.Organizations
|
||||
.Find(o => o.Id == id)
|
||||
.FirstOrDefaultAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>Creates a new organization in the database.</summary>
|
||||
public async Task CreateAsync(Organization organization, CancellationToken cancellationToken)
|
||||
{
|
||||
await _context.Organizations.InsertOneAsync(organization, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
await _context.Organizations
|
||||
.InsertOneAsync(organization, cancellationToken: cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
+13
-4
@@ -12,24 +12,33 @@ public class SessionRepository(MongoDbContext context)
|
||||
/// <summary>Finds a session by its token hash.</summary>
|
||||
public async Task<Session?> GetByTokenHashAsync(string tokenHash, CancellationToken cancellationToken)
|
||||
{
|
||||
return await _context.Sessions.Find(s => s.TokenHash == tokenHash).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
|
||||
return await _context.Sessions
|
||||
.Find(s => s.TokenHash == tokenHash)
|
||||
.FirstOrDefaultAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>Creates a new session in the database.</summary>
|
||||
public async Task CreateAsync(Session session, CancellationToken cancellationToken)
|
||||
{
|
||||
await _context.Sessions.InsertOneAsync(session, new InsertOneOptions(), cancellationToken).ConfigureAwait(false);
|
||||
await _context.Sessions
|
||||
.InsertOneAsync(session, new InsertOneOptions(), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>Deletes a session by its unique identifier.</summary>
|
||||
public async Task DeleteAsync(string id, CancellationToken cancellationToken)
|
||||
{
|
||||
_ = await _context.Sessions.DeleteOneAsync(s => s.Id == id, cancellationToken).ConfigureAwait(false);
|
||||
_ = await _context.Sessions
|
||||
.DeleteOneAsync(s => s.Id == id, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>Deletes all sessions for a given user.</summary>
|
||||
public async Task DeleteByUserIdAsync(string userId, CancellationToken cancellationToken)
|
||||
{
|
||||
_ = await _context.Sessions.DeleteManyAsync(s => s.UserId == userId, cancellationToken).ConfigureAwait(false);
|
||||
_ = await _context.Sessions
|
||||
.DeleteManyAsync(s => s.UserId == userId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
using MongoDB.Driver;
|
||||
|
||||
using ParagonPlayground.Domain.Entities;
|
||||
|
||||
namespace ParagonPlayground.Infrastructure.Data;
|
||||
|
||||
/// <summary>Repository for storage item (file/folder) data access.</summary>
|
||||
public class StorageItemRepository(MongoDbContext context)
|
||||
{
|
||||
private readonly MongoDbContext _context = context;
|
||||
|
||||
/// <summary>Lists items in a folder (or root items when parentId is null).</summary>
|
||||
public async Task<List<StorageItem>> GetByParentIdAsync(
|
||||
string organizationId, string? parentId, CancellationToken ct)
|
||||
{
|
||||
var filter = Builders<StorageItem>.Filter.Eq(i => i.OrganizationId, organizationId)
|
||||
& Builders<StorageItem>.Filter.Eq(i => i.ParentId, parentId);
|
||||
|
||||
return await _context.StorageItems.Find(filter)
|
||||
.SortByDescending(i => i.IsFolder)
|
||||
.ThenBy(i => i.Name)
|
||||
.ToListAsync(ct)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>Finds a storage item by ID.</summary>
|
||||
public async Task<StorageItem?> GetByIdAsync(string id, CancellationToken ct)
|
||||
{
|
||||
return await _context.StorageItems.Find(i => i.Id == id)
|
||||
.FirstOrDefaultAsync(ct)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>Creates a new storage item.</summary>
|
||||
public async Task CreateAsync(StorageItem item, CancellationToken ct)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(item);
|
||||
|
||||
await _context.StorageItems
|
||||
.InsertOneAsync(item, cancellationToken: ct)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>Deletes a storage item by ID.</summary>
|
||||
public async Task DeleteAsync(string id, CancellationToken ct)
|
||||
{
|
||||
_ = await _context.StorageItems
|
||||
.DeleteOneAsync(i => i.Id == id, ct)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
using MongoDB.Driver;
|
||||
|
||||
using ParagonPlayground.Domain.Entities;
|
||||
|
||||
namespace ParagonPlayground.Infrastructure.Data;
|
||||
|
||||
/// <summary>Repository for user credential data access.</summary>
|
||||
public class UserCredentialRepository(MongoDbContext context)
|
||||
{
|
||||
private readonly MongoDbContext _context = context;
|
||||
|
||||
/// <summary>Finds all credentials for a given user.</summary>
|
||||
public async Task<List<UserCredential>> GetByUserIdAsync(string userId, CancellationToken ct)
|
||||
{
|
||||
return await _context.UserCredentials
|
||||
.Find(c => c.UserId == userId)
|
||||
.ToListAsync(ct)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>Finds all credentials for a given organization.</summary>
|
||||
public async Task<List<UserCredential>> GetByOrganizationIdAsync(string organizationId, CancellationToken ct)
|
||||
{
|
||||
return await _context.UserCredentials
|
||||
.Find(c => c.OrganizationId == organizationId)
|
||||
.ToListAsync(ct)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>Stores a new credential mapping.</summary>
|
||||
public async Task CreateAsync(UserCredential credential, CancellationToken ct)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(credential);
|
||||
|
||||
await _context.UserCredentials
|
||||
.InsertOneAsync(credential, cancellationToken: ct)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>Deletes all credentials for a given organization.</summary>
|
||||
public async Task<long> DeleteByOrganizationIdAsync(string organizationId, CancellationToken ct)
|
||||
{
|
||||
var result = await _context.UserCredentials
|
||||
.DeleteManyAsync(c => c.OrganizationId == organizationId, ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return result.DeletedCount;
|
||||
}
|
||||
|
||||
/// <summary>Deletes a credential by its Paragon credential ID for a given user.</summary>
|
||||
public async Task<bool> DeleteByCredentialIdAsync(string credentialId, string userId, CancellationToken ct)
|
||||
{
|
||||
var filter = Builders<UserCredential>.Filter.And(
|
||||
Builders<UserCredential>.Filter.Eq(c => c.CredentialId, credentialId),
|
||||
Builders<UserCredential>.Filter.Eq(c => c.UserId, userId)
|
||||
);
|
||||
|
||||
var result = await _context.UserCredentials
|
||||
.DeleteOneAsync(filter, ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return result.DeletedCount > 0;
|
||||
}
|
||||
}
|
||||
+17
-2
@@ -18,12 +18,27 @@ public class UserRepository(MongoDbContext context)
|
||||
/// <summary>Finds a user by their unique identifier.</summary>
|
||||
public async Task<User?> GetByIdAsync(string id, CancellationToken cancellationToken)
|
||||
{
|
||||
return await _context.Users.Find(u => u.Id == id).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
|
||||
return await _context.Users
|
||||
.Find(u => u.Id == id)
|
||||
.FirstOrDefaultAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>Creates a new user in the database.</summary>
|
||||
public async Task CreateAsync(User user, CancellationToken cancellationToken)
|
||||
{
|
||||
await _context.Users.InsertOneAsync(user, new InsertOneOptions(), cancellationToken).ConfigureAwait(false);
|
||||
await _context.Users
|
||||
.InsertOneAsync(user, new InsertOneOptions(), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>Replaces an existing user document.</summary>
|
||||
public async Task ReplaceAsync(User user, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(user);
|
||||
|
||||
_ = await _context.Users
|
||||
.ReplaceOneAsync(u => u.Id == user.Id, user, cancellationToken: cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user