Skip to content
Snippets Groups Projects
Sender.cs 19.9 KiB
Newer Older
using System.Text.RegularExpressions;
using Autofac;
using FitConnect.Encryption;
using FitConnect.Exceptions;
Klaus Fischer's avatar
Klaus Fischer committed
using FitConnect.Interfaces;
using FitConnect.Models;
using FitConnect.Models.Api.Metadata;
Klaus Fischer's avatar
Klaus Fischer committed
using FitConnect.Models.Api.Set;
Klaus Fischer's avatar
Klaus Fischer committed
using FitConnect.Services.Models.v1.Submission;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using Attachment = FitConnect.Models.Attachment;
using Metadata = FitConnect.Models.Metadata;

namespace FitConnect;

/// <summary>
///     The fluent implementation for the <see cref="Sender" /> to encapsulate the FitConnect API.
/// </summary>
/// <example>
///     Reference for FluentSender
///     <code>
/// client.Sender
///     .Authenticate(clientId!, clientSecret!)
///     .CreateSubmission(new Submission { Attachments = new List() })
///     .UploadAttachments()
///     .WithData(data)
/// </code>
/// </example>
Klaus Fischer's avatar
Klaus Fischer committed
public class Sender : FitConnectClient, ISender {
    internal Sender(FitConnectEnvironment environment, string clientId, string clientSecret,
        ILogger? logger = null) : base(environment, clientId, clientSecret, logger) {
    public Sender(FitConnectEnvironment environment, string clientId, string clientSecret,
        IContainer container) : base(environment, clientId, clientSecret,
        container) {
    }
Klaus Fischer's avatar
Klaus Fischer committed

    public string? PublicKey { get; set; }

Klaus Fischer's avatar
Klaus Fischer committed
    /// <summary>
    ///     Sender for pre-encrypted data
    /// </summary>
    /// <param name="submission"></param>
    /// <returns></returns>
    /// <exception cref="ArgumentNullException"></exception>
    /// <exception cref="ArgumentException"></exception>
    public async Task<SentSubmission> SendAsync(SendableEncryptedSubmission submission) {
        await SubmissionSenderGuards(submission, null);
        var sendable = await PrepareEncryptedSubmission(submission);
        return (SentSubmission)(await FinishSubmissionTransmission(sendable));
    }

Klaus Fischer's avatar
Klaus Fischer committed
    public async Task<string> GetPublicKeyForDestinationAsync(Guid destinationId) {
Klaus Fischer's avatar
Klaus Fischer committed
        var publicKey = await DestinationService.GetPublicKey(destinationId);
Klaus Fischer's avatar
Klaus Fischer committed
        var keyIsValid = new CertificateHelper(Logger).ValidateCertificateJsonWebKeyString(
            publicKey,
Klaus Fischer's avatar
Klaus Fischer committed
            VerifiedCertificatesAreMandatory ? LogLevel.Error : LogLevel.Warning);
Klaus Fischer's avatar
Klaus Fischer committed
        if (VerifiedCertificatesAreMandatory && !await keyIsValid)
Klaus Fischer's avatar
Klaus Fischer committed
            throw new SecurityException("Public key is not trusted");
Klaus Fischer's avatar
Klaus Fischer committed

Klaus Fischer's avatar
Klaus Fischer committed
        return publicKey;
Klaus Fischer's avatar
Klaus Fischer committed
    /// <summary>
    ///     Creates a new <see cref="SendableSubmission" /> on the FitConnect server.
    /// </summary>
    /// <param name="sendableSubmission"></param>
    /// <returns></returns>
    /// <exception cref="InvalidOperationException">If sender is not authenticated</exception>
    /// <exception cref="ArgumentException">If submission is not ready to be sent</exception>
    private async Task<Submission> CreateSubmissionFromSendable(
        SendableSubmission sendableSubmission) {
        Guid destinationId = sendableSubmission.DestinationId;
        PublicKey = await GetPublicKeyForDestinationAsync(destinationId);
        Encryption = new FitEncryption(Logger) { PublicEncryptionKey = PublicKey };
        return sendableSubmission.ToSubmission();
    }
Klaus Fischer's avatar
Klaus Fischer committed
    /// <summary>
    ///     Creates a new <see cref="SendableSubmission" /> on the FitConnect server.
    /// </summary>
    /// <param name="sendableSubmission"></param>
    /// <returns></returns>
    /// <exception cref="InvalidOperationException">If sender is not authenticated</exception>
    /// <exception cref="ArgumentException">If submission is not ready to be sent</exception>
    private async Task<Submission>
        CreateSubmissionFromSendable(ISendableSubmission sendableSubmission) {
        var destinationId = sendableSubmission.DestinationId;
        PublicKey = await GetPublicKeyForDestinationAsync(destinationId);
        Encryption = new FitEncryption(Logger) { PublicEncryptionKey = PublicKey };
        return new Submission {
            DestinationId = destinationId,
            EncryptedData = sendableSubmission.Data,
        };
Klaus Fischer's avatar
Klaus Fischer committed

    private async Task<Submission> IntroduceSubmission(SendableSubmission sendable) {
        var submission = await CreateSubmissionFromSendable(sendable);

        submission.AddServiceType(sendable.ServiceName!, sendable.ServiceIdentifier!);
        submission!.Attachments = new List<Attachment>();
        if (sendable.Attachments != null)
            foreach (var attachment in sendable.Attachments)
                submission!.Attachments.Add(attachment);
Klaus Fischer's avatar
Klaus Fischer committed
        var created =
            await SubmissionService.CreateSubmissionOnServerAsync((CreateSubmissionDto)submission);
        submission.Id = created.SubmissionId;
        submission.CaseId = created.CaseId;
Klaus Fischer's avatar
Klaus Fischer committed
        Logger?.LogInformation("Submission Id {CreatedSubmissionId}, CaseId {SubmissionCaseId}",
            created.SubmissionId, submission.CaseId);
        var encryptedAttachments = Encrypt(PublicKey!, submission.Attachments);
        await UploadAttachmentsAsync(submission.Id!, encryptedAttachments);
        foreach (var (key, value) in encryptedAttachments) {
            var item = sendable.Attachments!.Single(attachment => attachment.Id == key);
            item.AttachmentAuthentication = value.Split('.').Last();
        }

        return submission;
Klaus Fischer's avatar
Klaus Fischer committed

    public async Task<SubmissionStatus> GetStatusForSubmissionAsync(SentSubmission submission) {
        try {
            return await base.GetStatusForSubmissionAsync(submission, true);
        }
        catch (Exception e) {
            Logger?.LogError("Error while getting status for submission");
            throw new FitConnectSenderException("Error while getting status for submission", e);
        }
Klaus Fischer's avatar
Klaus Fischer committed
    private async Task HandleMetadata(Submission submission) {
        if (submission.EncryptedMetadata == null) {
            var metadata = CreateMetadata(submission);
            submission.GeneratedMetadata = metadata;
            Logger?.LogTrace("Metadata: {Metadata}", metadata);
            var valid = await JsonHelper.VerifyMetadata(metadata, null, Logger);
            if (!valid) {
                Logger?.LogError("Sending submission aborted due to validation errors");
                throw new InvalidOperationException("Submission is not ready");
            }
Klaus Fischer's avatar
Klaus Fischer committed
            Logger?.LogInformation("Metadata validation check, done");
            Logger?.LogInformation("Sending submission");
            var encryptedMeta = Encryption.Encrypt(metadata);
            Logger?.LogTrace("Encrypted metadata: {EncryptedMeta}", encryptedMeta);
            submission.EncryptedMetadata = encryptedMeta;
        }
Klaus Fischer's avatar
Klaus Fischer committed
    }
Klaus Fischer's avatar
Klaus Fischer committed
    private async Task<Submission> FinishSubmissionTransmission(Submission submission) {
        if (submission == null) {
            Logger?.LogCritical("Submission is null on submit");
            throw new InvalidOperationException("Submission is not ready");
        }
Klaus Fischer's avatar
Klaus Fischer committed
        await HandleMetadata(submission);
Klaus Fischer's avatar
Klaus Fischer committed
        if (submission.EncryptedData == null)
            if (submission.Data != null)
                submission.EncryptedData = Encryption.Encrypt(submission.Data);
Klaus Fischer's avatar
Klaus Fischer committed
        await SubmissionService
            .SubmitSubmission(submission.Id!, (SubmitSubmissionDto)submission);
Klaus Fischer's avatar
Klaus Fischer committed

Klaus Fischer's avatar
Klaus Fischer committed
        Logger?.LogInformation("Submission sent");
        return submission;
    private async Task<Submission> PrepareEncryptedSubmission(
        SendableEncryptedSubmission submission) {
        var sendable = await CreateSubmissionFromSendable(submission);
Klaus Fischer's avatar
Klaus Fischer committed
        sendable.AddServiceType(submission.ServiceName!, submission.ServiceIdentifier!);
Klaus Fischer's avatar
Klaus Fischer committed
        sendable.EncryptedMetadata = submission.Metadata;
Klaus Fischer's avatar
Klaus Fischer committed
        var announcedSubmission = (CreateSubmissionDto)sendable;
        announcedSubmission.AnnouncedAttachments = submission.Attachments?.Keys.ToList();
Klaus Fischer's avatar
Klaus Fischer committed
        var created =
            await SubmissionService.CreateSubmissionOnServerAsync(announcedSubmission);
Klaus Fischer's avatar
Klaus Fischer committed
        sendable.Id = created.SubmissionId;
        sendable.CaseId = created.CaseId;
Klaus Fischer's avatar
Klaus Fischer committed
        Logger?.LogInformation("Submission Id {CreatedSubmissionId}, CaseId {SubmissionCaseId}",
            created.SubmissionId, sendable.CaseId);
Klaus Fischer's avatar
Klaus Fischer committed
        Logger?.LogInformation("Uploading pre encrypted attachments");
        await UploadAttachmentsAsync(sendable.Id!, submission.Attachments!);
        if (submission.Attachments != null) {
            sendable.Attachments ??= new List<Attachment>();
            foreach (var (key, value) in submission.Attachments) {
                sendable.Attachments.Add(
                    Attachment.FromPreEncryptedAttachment(key, value.Split('.').Last()));
            }
        }
        return sendable;
Klaus Fischer's avatar
Klaus Fischer committed
    public async Task<SentSubmission> SendAsync(SendableSubmission submission) {
        try {
            await SubmissionSenderGuards(submission, submission.DataMimeType);
            var introducedSubmission = await IntroduceSubmission(submission);
            return (SentSubmission)await FinishSubmissionTransmission(introducedSubmission);
        }
        catch (Exception e) {
            Logger?.LogError(e, "Error while sending submission");
            throw new FitConnectSenderException("Error while sending submission", e);
        }
    }
Klaus Fischer's avatar
Klaus Fischer committed
    internal override Task<List<SecurityEventToken>> GetEventLogAsync(Guid caseId,
        Guid destinationId, bool skipTest = false) {
        return base.GetEventLogAsync(caseId, destinationId, true);
    }
Klaus Fischer's avatar
Klaus Fischer committed
    public override Task<List<SecurityEventToken>> GetEventLogAsync(
        SubmissionForPickup submission, bool skipTest = false) {
        return base.GetEventLogAsync(submission, true);
Klaus Fischer's avatar
Klaus Fischer committed
    private async Task<Submission> Submit(Submission submission) {
        if (submission == null) {
            Logger?.LogCritical("Submission is null on submit");
            throw new InvalidOperationException("Submission is not ready");
Klaus Fischer's avatar
Klaus Fischer committed
        if (submission.EncryptedMetadata == null) {
            var metadata = CreateMetadata(submission);
            submission.GeneratedMetadata = metadata;
Klaus Fischer's avatar
Klaus Fischer committed
            Logger?.LogTrace("Metadata: {Metadata}", metadata);
            var valid = await JsonHelper.VerifyMetadata(metadata, null, Logger);
Klaus Fischer's avatar
Klaus Fischer committed
            if (!valid) {
                Logger?.LogError("Sending submission aborted due to validation errors");
                throw new InvalidOperationException("Submission is not ready");
            }
Klaus Fischer's avatar
Klaus Fischer committed
            Logger?.LogInformation("Metadata validation check, done");
Klaus Fischer's avatar
Klaus Fischer committed
            Logger?.LogInformation("Sending submission");
            var encryptedMeta = Encryption.Encrypt(metadata);
            Logger?.LogTrace("Encrypted metadata: {EncryptedMeta}", encryptedMeta);
            submission.EncryptedMetadata = encryptedMeta;
Klaus Fischer's avatar
Klaus Fischer committed
        if (submission.EncryptedData == null)
            if (submission.Data != null)
                submission.EncryptedData = Encryption.Encrypt(submission.Data);
Klaus Fischer's avatar
Klaus Fischer committed
        var result = await SubmissionService
            .SubmitSubmission(submission.Id!, (SubmitSubmissionDto)submission);
Klaus Fischer's avatar
Klaus Fischer committed
        Logger?.LogInformation("Submission sent");
        return submission;
Klaus Fischer's avatar
Klaus Fischer committed
    /// <summary>
Klaus Fischer's avatar
Klaus Fischer committed
    ///     Creates a new <see cref="SendableSubmission" /> on the FitConnect server.
Klaus Fischer's avatar
Klaus Fischer committed
    /// </summary>
Klaus Fischer's avatar
Klaus Fischer committed
    /// <param name="sendableSubmission"></param>
Klaus Fischer's avatar
Klaus Fischer committed
    /// <returns></returns>
    /// <exception cref="InvalidOperationException">If sender is not authenticated</exception>
    /// <exception cref="ArgumentException">If submission is not ready to be sent</exception>
Klaus Fischer's avatar
Klaus Fischer committed
    private async Task<Submission> CreateSubmission(SendableSubmission sendableSubmission) {
        Guid destinationId = sendableSubmission.DestinationId;
Klaus Fischer's avatar
Klaus Fischer committed
        PublicKey = await GetPublicKeyForDestinationAsync(destinationId);
Klaus Fischer's avatar
Klaus Fischer committed
        Encryption = new FitEncryption(Logger) { PublicEncryptionKey = PublicKey };
        return sendableSubmission.ToSubmission();
    }
Klaus Fischer's avatar
Klaus Fischer committed
    /// <summary>
    ///     Creates a new <see cref="SendableSubmission" /> on the FitConnect server.
    /// </summary>
    /// <param name="sendableSubmission"></param>
    /// <returns></returns>
    /// <exception cref="InvalidOperationException">If sender is not authenticated</exception>
    /// <exception cref="ArgumentException">If submission is not ready to be sent</exception>
    private async Task<Submission>
        CreateSubmission(ISendableSubmission sendableSubmission) {
        var destinationId = sendableSubmission.DestinationId;
        PublicKey = await GetPublicKeyForDestinationAsync(destinationId);
        Encryption = new FitEncryption(Logger) { PublicEncryptionKey = PublicKey };
        return new Submission {
            DestinationId = destinationId
        };
    }
Klaus Fischer's avatar
Klaus Fischer committed
    /// <summary>
    ///     Create Metadata incl. Hash
Klaus Fischer's avatar
Klaus Fischer committed
    /// </summary>
    /// <param name="submission"></param>
Klaus Fischer's avatar
Klaus Fischer committed
    /// <param name="dataHash"></param>
Klaus Fischer's avatar
Klaus Fischer committed
    /// <returns></returns>
Klaus Fischer's avatar
Klaus Fischer committed
    internal static string CreateMetadata(Submission submission,
        DataHash? dataHash = null) {
Klaus Fischer's avatar
Klaus Fischer committed
        if (submission.Data == null)
            throw new FitConnectSenderException("Submission data is null");

        var data = new Data {
Klaus Fischer's avatar
Klaus Fischer committed
            Hash = dataHash ?? FitEncryption.CalculateHash(submission.Data),
Klaus Fischer's avatar
Klaus Fischer committed
            SubmissionSchema = new SubmissionSchema {
                SchemaUri = submission.SchemaUri,
Klaus Fischer's avatar
Klaus Fischer committed
                MimeType = submission.DataMimeType
        };
        var contentStructure = new ContentStructure {
            Data = data,
Klaus Fischer's avatar
Klaus Fischer committed
            Attachments = submission.Attachments?.Select(attachment =>
                new Models.Api.Metadata.AttachmentMetadata {
                    Description = attachment.Description,
                    AttachmentId = attachment.Id,
                    MimeType = attachment.MimeType,
                    Filename = attachment.Filename,
                    Purpose = attachment.Purpose,
                    Hash = attachment.Hash
                }).ToList() ?? new List<Models.Api.Metadata.AttachmentMetadata>()
Klaus Fischer's avatar
Klaus Fischer committed
        var metadata = new Metadata {
            SchemaUri = new Uri(Metadata.SchemaUrl),
Klaus Fischer's avatar
Klaus Fischer committed
            ContentStructure = contentStructure,
Klaus Fischer's avatar
Klaus Fischer committed
            ReplyChannel = submission.ReplyChannel,
            PaymentInformation = submission.PaymentInformation,
            AuthenticationInformation = submission.AuthenticationInformation?.ToList()
Klaus Fischer's avatar
Klaus Fischer committed
        };
Klaus Fischer's avatar
Klaus Fischer committed
        return JsonConvert.SerializeObject(metadata, Formatting.None, new JsonSerializerSettings() {
            Converters = new List<JsonConverter>() {
                new StatusConverter(),
                new PaymentMethodConverter(),
                new AuthenticationInformationTypeConverter()
            },
            NullValueHandling = NullValueHandling.Ignore,
        });
Klaus Fischer's avatar
Klaus Fischer committed

    /// <summary>
    ///     Finding Areas
    /// </summary>
    /// <param name="filter"></param>
Klaus Fischer's avatar
Klaus Fischer committed
    /// <param name="offset">How many entries of the result should be skipped</param>
    /// <param name="limit">How many result sets should be loaded</param>
    /// <returns></returns>
Klaus Fischer's avatar
Klaus Fischer committed
    public async Task<IEnumerable<Area>> GetAreas(string filter, int offset = 0,
        int limit = 100) {
Klaus Fischer's avatar
Klaus Fischer committed
        var dto = await RouteService.FindAreas(new List<string>() { filter }, offset, limit);
Klaus Fischer's avatar
Klaus Fischer committed
        // totalCount = dto?.TotalCount ?? 0;
        return dto?.Areas ?? new List<Area>();
    }
Klaus Fischer's avatar
Klaus Fischer committed
    /// <summary>
    ///     Uploading the encrypted data to the server
Klaus Fischer's avatar
Klaus Fischer committed
    /// </summary>
Klaus Fischer's avatar
Klaus Fischer committed
    /// <param name="submissionId">Submissions ID</param>
Klaus Fischer's avatar
Klaus Fischer committed
    /// <param name="encryptedAttachments">Encrypted attachments with id and content</param>
Klaus Fischer's avatar
Klaus Fischer committed
    private async Task<bool> UploadAttachmentsAsync(Guid submissionId,
        Dictionary<Guid, string> encryptedAttachments) {
        try {
            foreach (var (id, content) in encryptedAttachments) {
                Logger?.LogInformation("Uploading attachment {Id}", id);
Klaus Fischer's avatar
Klaus Fischer committed
                await SubmissionService.UploadAttachment(submissionId, id, content);
            }

            return true;
        }
        catch (Exception e) {
Klaus Fischer's avatar
Klaus Fischer committed
            Logger?.LogError("Error Uploading attachment {message}", e.Message);
            throw;
Klaus Fischer's avatar
Klaus Fischer committed

    /// <summary>
    /// Checking the submission if it is ready to be sent
    /// Locally identified errors are thrown as exceptions 
    /// </summary>
    private async Task
        SubmissionSenderGuards(ISendableSubmission submission, string? dataMimeType) {
Klaus Fischer's avatar
Klaus Fischer committed
        if (submission.ServiceName == null)
            throw new ArgumentNullException(nameof(SendableSubmission.ServiceName));

Klaus Fischer's avatar
Klaus Fischer committed
        if (submission.ServiceIdentifier == null)
            throw new ArgumentNullException(nameof(SendableSubmission.ServiceIdentifier));
Klaus Fischer's avatar
Klaus Fischer committed

        if (submission.Data == null)
            throw new ArgumentNullException(nameof(SendableSubmission.Data));
Klaus Fischer's avatar
Klaus Fischer committed
        if (new Regex(ServiceIdentifierPattern).IsMatch(submission.ServiceIdentifier) == false)
            throw new ArgumentException("The service identifier is not valid. " +
                                        "It must match the pattern " + ServiceIdentifierPattern);

        await CheckServiceWithMimeType(submission, dataMimeType);
    }

    /// <summary>
    /// Checks the destination for service for ServiceIdentifier, SchemaUri and MimeType
    /// </summary>
    private async Task CheckServiceWithMimeType(ISendableSubmission submission,
        string? dataMimeType) {
Klaus Fischer's avatar
Klaus Fischer committed
        var destination = await DestinationService.GetDestinationAsync(submission.DestinationId);
        if (destination == null)
            throw new Exception("Destination not found");
        var serviceIdentifierMatch =
Klaus Fischer's avatar
Klaus Fischer committed
            destination?.Services?.Any(s => s.Identifier == submission.ServiceIdentifier) ??
            false;
        if (!serviceIdentifierMatch)
            throw new ArgumentException("The service identifier is not valid for the destination");
        if (submission is SendableSubmission && submission.SchemaUri == null)
Klaus Fischer's avatar
Klaus Fischer committed
            throw new ArgumentException("Argument SchemaUri is required");
        // Searching a service with the given service identifier
        var matchingServiceIdentifier = destination?.Services?.Where(s =>
            s.Identifier == submission.ServiceIdentifier);

        // Searching a service with the given mime type
        var matchingService = matchingServiceIdentifier?.FirstOrDefault(s =>
            s.SubmissionSchemas?.Any(schema =>
                dataMimeType == null || schema.mimeType == dataMimeType) ?? false);

        if (matchingService == null)
            throw new ArgumentException(
                "The service identifier with the given mime type is not valid for this destination");

        if (matchingService.SubmissionSchemas == null)
            throw new ArgumentException("Schemas property for service is empty");
        if (submission is SendableEncryptedSubmission) return;

        // Checking for the given schema uri
        var matchingSchema = matchingService.SubmissionSchemas.FirstOrDefault(schema =>
            schema.SchemaUri.ToString() == submission.SchemaUri?.ToString()
        );

        if (matchingSchema == null)
Klaus Fischer's avatar
Klaus Fischer committed
            throw new ArgumentException("The schema uri is not valid for the destination");

        if (dataMimeType != null) {
            if (matchingSchema.mimeType == dataMimeType) return;
            throw new ArgumentException("The data mime type is not valid for the destination");
        }
Klaus Fischer's avatar
Klaus Fischer committed
internal static class SubmissionSenderExtension {
Klaus Fischer's avatar
Klaus Fischer committed
    public static void AddServiceType(this Submission submission, string serviceName,
Klaus Fischer's avatar
Klaus Fischer committed
        string serviceIdentifier) {
        if (string.IsNullOrWhiteSpace(serviceIdentifier) || !Regex.IsMatch(serviceIdentifier,
                FitConnectClient.ServiceIdentifierPattern))
            throw new ArgumentException("Invalid service identifier");
Klaus Fischer's avatar
Klaus Fischer committed

        submission.ServiceType = new ServiceType {
            Name = serviceName,
Klaus Fischer's avatar
Klaus Fischer committed
            Identifier = serviceIdentifier
Klaus Fischer's avatar
Klaus Fischer committed
        };
    }

    public static void AddData(this Submission submission, string data) {
        try {
            JsonConvert.DeserializeObject(data);
        }
        catch (Exception e) {
            throw new ArgumentException("The data must be valid JSON string", e);
        }

        submission.Data = data;
    }
Klaus Fischer's avatar
Klaus Fischer committed
}