using System.Diagnostics.CodeAnalysis; using System.Net.Http.Headers; using System.Net.Http.Json; using System.Text.Encodings.Web; using System.Text.Json; using Bit.Core.Enums; using Bit.Core.Models.Data; using Bit.Core.Platform.Push; using Bit.Core.Repositories; using Bit.Core.Utilities; using Microsoft.Azure.NotificationHubs; using Microsoft.Extensions.Logging; namespace Bit.Core.NotificationHub; #nullable enable public class NotificationHubPushRegistrationService : IPushRegistrationService { private static readonly JsonSerializerOptions webPushSerializationOptions = new() { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }; private readonly IInstallationDeviceRepository _installationDeviceRepository; private readonly INotificationHubPool _notificationHubPool; private readonly IHttpClientFactory _httpClientFactory; private readonly ILogger _logger; public NotificationHubPushRegistrationService( IInstallationDeviceRepository installationDeviceRepository, INotificationHubPool notificationHubPool, IHttpClientFactory httpClientFactory, ILogger logger) { _installationDeviceRepository = installationDeviceRepository; _notificationHubPool = notificationHubPool; _httpClientFactory = httpClientFactory; _logger = logger; } public async Task CreateOrUpdateRegistrationAsync(PushRegistrationData data, string deviceId, string userId, string? identifier, DeviceType type, IEnumerable organizationIds, Guid installationId) { var orgIds = organizationIds.ToList(); var clientType = DeviceTypes.ToClientType(type); var installation = new Installation { InstallationId = deviceId, PushChannel = data.Token, Tags = new List { $"userId:{userId}", $"clientType:{clientType}" }.Concat(orgIds.Select(organizationId => $"organizationId:{organizationId}")).ToList(), Templates = new Dictionary() }; if (!string.IsNullOrWhiteSpace(identifier)) { installation.Tags.Add("deviceIdentifier:" + identifier); } if (installationId != Guid.Empty) { installation.Tags.Add($"installationId:{installationId}"); } if (data.Token != null) { await CreateOrUpdateMobileRegistrationAsync(installation, userId, identifier, clientType, orgIds, type, installationId); } else if (data.WebPush != null) { await CreateOrUpdateWebRegistrationAsync(data.WebPush.Value.Endpoint, data.WebPush.Value.P256dh, data.WebPush.Value.Auth, installation, userId, identifier, clientType, orgIds, installationId); } if (InstallationDeviceEntity.IsInstallationDeviceId(deviceId)) { await _installationDeviceRepository.UpsertAsync(new InstallationDeviceEntity(deviceId)); } } private async Task CreateOrUpdateMobileRegistrationAsync(Installation installation, string userId, string? identifier, ClientType clientType, List organizationIds, DeviceType type, Guid installationId) { if (string.IsNullOrWhiteSpace(installation.PushChannel)) { return; } switch (type) { case DeviceType.Android: installation.Templates.Add(BuildInstallationTemplate("payload", "{\"message\":{\"data\":{\"type\":\"$(type)\",\"payload\":\"$(payload)\"}}}", userId, identifier, clientType, organizationIds, installationId)); installation.Templates.Add(BuildInstallationTemplate("message", "{\"message\":{\"data\":{\"type\":\"$(type)\"}," + "\"notification\":{\"title\":\"$(title)\",\"body\":\"$(message)\"}}}", userId, identifier, clientType, organizationIds, installationId)); installation.Templates.Add(BuildInstallationTemplate("badgeMessage", "{\"message\":{\"data\":{\"type\":\"$(type)\"}," + "\"notification\":{\"title\":\"$(title)\",\"body\":\"$(message)\"}}}", userId, identifier, clientType, organizationIds, installationId)); installation.Platform = NotificationPlatform.FcmV1; break; case DeviceType.iOS: installation.Templates.Add(BuildInstallationTemplate("payload", "{\"data\":{\"type\":\"#(type)\",\"payload\":\"$(payload)\"}," + "\"aps\":{\"content-available\":1}}", userId, identifier, clientType, organizationIds, installationId)); installation.Templates.Add(BuildInstallationTemplate("message", "{\"data\":{\"type\":\"#(type)\"}," + "\"aps\":{\"alert\":\"$(message)\",\"badge\":null,\"content-available\":1}}", userId, identifier, clientType, organizationIds, installationId)); installation.Templates.Add(BuildInstallationTemplate("badgeMessage", "{\"data\":{\"type\":\"#(type)\"}," + "\"aps\":{\"alert\":\"$(message)\",\"badge\":\"#(badge)\",\"content-available\":1}}", userId, identifier, clientType, organizationIds, installationId)); installation.Platform = NotificationPlatform.Apns; break; case DeviceType.AndroidAmazon: installation.Templates.Add(BuildInstallationTemplate("payload", "{\"data\":{\"type\":\"#(type)\",\"payload\":\"$(payload)\"}}", userId, identifier, clientType, organizationIds, installationId)); installation.Templates.Add(BuildInstallationTemplate("message", "{\"data\":{\"type\":\"#(type)\",\"message\":\"$(message)\"}}", userId, identifier, clientType, organizationIds, installationId)); installation.Templates.Add(BuildInstallationTemplate("badgeMessage", "{\"data\":{\"type\":\"#(type)\",\"message\":\"$(message)\"}}", userId, identifier, clientType, organizationIds, installationId)); installation.Platform = NotificationPlatform.Adm; break; default: break; } await ClientFor(GetComb(installation.InstallationId)).CreateOrUpdateInstallationAsync(installation); } private async Task CreateOrUpdateWebRegistrationAsync(string endpoint, string p256dh, string auth, Installation installation, string userId, string? identifier, ClientType clientType, List organizationIds, Guid installationId) { // The Azure SDK is currently lacking support for web push registrations. // We need to use the REST API directly. if (string.IsNullOrWhiteSpace(endpoint) || string.IsNullOrWhiteSpace(p256dh) || string.IsNullOrWhiteSpace(auth)) { return; } installation.Templates.Add(BuildInstallationTemplate("payload", "{\"data\":{\"type\":\"#(type)\",\"payload\":\"$(payload)\"}}", userId, identifier, clientType, organizationIds, installationId)); installation.Templates.Add(BuildInstallationTemplate("message", "{\"data\":{\"type\":\"#(type)\",\"message\":\"$(message)\"}}", userId, identifier, clientType, organizationIds, installationId)); installation.Templates.Add(BuildInstallationTemplate("badgeMessage", "{\"data\":{\"type\":\"#(type)\",\"message\":\"$(message)\"}}", userId, identifier, clientType, organizationIds, installationId)); var content = new { installationId = installation.InstallationId, pushChannel = new { endpoint, p256dh, auth }, platform = "browser", tags = installation.Tags, templates = installation.Templates }; var client = _httpClientFactory.CreateClient("NotificationHub"); var request = ConnectionFor(GetComb(installation.InstallationId)).CreateRequest(HttpMethod.Put, $"installations/{installation.InstallationId}"); request.Content = JsonContent.Create(content, new MediaTypeHeaderValue("application/json"), webPushSerializationOptions); var response = await client.SendAsync(request); var body = await response.Content.ReadAsStringAsync(); if (!response.IsSuccessStatusCode) { _logger.LogWarning("Web push registration failed: {Response}", body); } else { _logger.LogInformation("Web push registration success: {Response}", body); } } private static KeyValuePair BuildInstallationTemplate(string templateId, [StringSyntax(StringSyntaxAttribute.Json)] string templateBody, string userId, string? identifier, ClientType clientType, List organizationIds, Guid installationId) { var fullTemplateId = $"template:{templateId}"; var template = new InstallationTemplate { Body = templateBody, Tags = new List { fullTemplateId, $"{fullTemplateId}_userId:{userId}", $"clientType:{clientType}" } }; if (!string.IsNullOrWhiteSpace(identifier)) { template.Tags.Add($"{fullTemplateId}_deviceIdentifier:{identifier}"); } foreach (var organizationId in organizationIds) { template.Tags.Add($"organizationId:{organizationId}"); } if (installationId != Guid.Empty) { template.Tags.Add($"installationId:{installationId}"); } return new KeyValuePair(fullTemplateId, template); } public async Task DeleteRegistrationAsync(string deviceId) { try { await ClientFor(GetComb(deviceId)).DeleteInstallationAsync(deviceId); if (InstallationDeviceEntity.IsInstallationDeviceId(deviceId)) { await _installationDeviceRepository.DeleteAsync(new InstallationDeviceEntity(deviceId)); } } catch (Exception e) when (e.InnerException == null || !e.InnerException.Message.Contains("(404) Not Found")) { throw; } } public async Task AddUserRegistrationOrganizationAsync(IEnumerable deviceIds, string organizationId) { await PatchTagsForUserDevicesAsync(deviceIds, UpdateOperationType.Add, $"organizationId:{organizationId}"); if (deviceIds.Any() && InstallationDeviceEntity.IsInstallationDeviceId(deviceIds.First())) { var entities = deviceIds.Select(e => new InstallationDeviceEntity(e)); await _installationDeviceRepository.UpsertManyAsync(entities.ToList()); } } public async Task DeleteUserRegistrationOrganizationAsync(IEnumerable deviceIds, string organizationId) { await PatchTagsForUserDevicesAsync(deviceIds, UpdateOperationType.Remove, $"organizationId:{organizationId}"); if (deviceIds.Any() && InstallationDeviceEntity.IsInstallationDeviceId(deviceIds.First())) { var entities = deviceIds.Select(e => new InstallationDeviceEntity(e)); await _installationDeviceRepository.UpsertManyAsync(entities.ToList()); } } private async Task PatchTagsForUserDevicesAsync(IEnumerable deviceIds, UpdateOperationType op, string tag) { if (!deviceIds.Any()) { return; } var operation = new PartialUpdateOperation { Operation = op, Path = "/tags" }; if (op == UpdateOperationType.Add) { operation.Value = tag; } else if (op == UpdateOperationType.Remove) { operation.Path += $"/{tag}"; } foreach (var deviceId in deviceIds) { try { await ClientFor(GetComb(deviceId)).PatchInstallationAsync(deviceId, new List { operation }); } catch (Exception e) when (e.InnerException == null || !e.InnerException.Message.Contains("(404) Not Found")) { throw; } } } private INotificationHubClient ClientFor(Guid deviceId) { return _notificationHubPool.ClientFor(deviceId); } private NotificationHubConnection ConnectionFor(Guid deviceId) { return _notificationHubPool.ConnectionFor(deviceId); } private Guid GetComb(string deviceId) { var deviceIdString = deviceId; InstallationDeviceEntity installationDeviceEntity; Guid deviceIdGuid; if (InstallationDeviceEntity.TryParse(deviceIdString, out installationDeviceEntity)) { // Strip off the installation id (PartitionId). RowKey is the ID in the Installation's table. deviceIdString = installationDeviceEntity.RowKey; } if (Guid.TryParse(deviceIdString, out deviceIdGuid)) { } else { throw new Exception($"Invalid device id {deviceId}."); } return deviceIdGuid; } }