1
0
mirror of https://github.com/bitwarden/server synced 2026-01-09 03:53:42 +00:00

[PM-19659] Clean up Notifications code (#6244)

* Move PushType to Platform Folder

- Move the PushType next to the rest of push notification code
- Specifically exclude it from needing Platform code review
- Add tests establishing rules Platform has for usage of this enum, making it safe to have no owner

* Move NotificationHub code into Platform/Push directory

* Update NotificationHub namespace imports

* Add attribute for storing push type metadata

* Rename Push Engines to have PushEngine suffix

* Move Push Registration items to their own directory

* Push code move

* Add expected usage comment

* Add Push feature registration method

- Make method able to be called multipes times with no ill effects

* Add Push Registration service entrypoint and tests

* Use new service entrypoints

* Test changes
This commit is contained in:
Justin Baur
2025-08-26 13:30:37 -04:00
committed by GitHub
parent 7a63ae6315
commit e5159a3ba2
51 changed files with 849 additions and 205 deletions

View File

@@ -1,5 +1,4 @@
#nullable enable
using System.Text.Json;
using System.Text.Json;
using Azure.Storage.Queues;
using Bit.Core.Context;
using Bit.Core.Enums;
@@ -13,17 +12,16 @@ using Microsoft.Extensions.Logging;
namespace Bit.Core.Platform.Push.Internal;
public class AzureQueuePushNotificationService : IPushEngine
public class AzureQueuePushEngine : IPushEngine
{
private readonly QueueClient _queueClient;
private readonly IHttpContextAccessor _httpContextAccessor;
public AzureQueuePushNotificationService(
public AzureQueuePushEngine(
[FromKeyedServices("notifications")] QueueClient queueClient,
IHttpContextAccessor httpContextAccessor,
IGlobalSettings globalSettings,
ILogger<AzureQueuePushNotificationService> logger,
TimeProvider timeProvider)
ILogger<AzureQueuePushEngine> logger)
{
_queueClient = queueClient;
_httpContextAccessor = httpContextAccessor;

View File

@@ -1,5 +1,4 @@
#nullable enable
using Bit.Core.Enums;
using Bit.Core.Enums;
using Bit.Core.Settings;
using Bit.Core.Vault.Entities;
using Microsoft.Extensions.Logging;
@@ -8,7 +7,7 @@ namespace Bit.Core.Platform.Push.Internal;
public class MultiServicePushNotificationService : IPushNotificationService
{
private readonly IEnumerable<IPushEngine> _services;
private readonly IPushEngine[] _services;
public Guid InstallationId { get; }
@@ -22,7 +21,8 @@ public class MultiServicePushNotificationService : IPushNotificationService
GlobalSettings globalSettings,
TimeProvider timeProvider)
{
_services = services;
// Filter out any NoopPushEngine's
_services = [.. services.Where(engine => engine is not NoopPushEngine)];
Logger = logger;
Logger.LogInformation("Hub services: {Services}", _services.Count());

View File

@@ -1,10 +1,9 @@
#nullable enable
using Bit.Core.Enums;
using Bit.Core.Enums;
using Bit.Core.Vault.Entities;
namespace Bit.Core.Platform.Push.Internal;
internal class NoopPushNotificationService : IPushEngine
internal class NoopPushEngine : IPushEngine
{
public Task PushCipherAsync(Cipher cipher, PushType pushType, IEnumerable<Guid>? collectionIds) => Task.CompletedTask;

View File

@@ -1,5 +1,4 @@
#nullable enable
using Bit.Core.Context;
using Bit.Core.Context;
using Bit.Core.Enums;
using Bit.Core.Models;
using Bit.Core.Services;
@@ -8,23 +7,22 @@ using Bit.Core.Vault.Entities;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
// This service is not in the `Internal` namespace because it has direct external references.
namespace Bit.Core.Platform.Push;
namespace Bit.Core.Platform.Push.Internal;
/// <summary>
/// Sends non-mobile push notifications to the Azure Queue Api, later received by Notifications Api.
/// Used by Cloud-Hosted environments.
/// Received by AzureQueueHostedService message receiver in Notifications project.
/// </summary>
public class NotificationsApiPushNotificationService : BaseIdentityClientService, IPushEngine
public class NotificationsApiPushEngine : BaseIdentityClientService, IPushEngine
{
private readonly IHttpContextAccessor _httpContextAccessor;
public NotificationsApiPushNotificationService(
public NotificationsApiPushEngine(
IHttpClientFactory httpFactory,
GlobalSettings globalSettings,
IHttpContextAccessor httpContextAccessor,
ILogger<NotificationsApiPushNotificationService> logger)
ILogger<NotificationsApiPushEngine> logger)
: base(
httpFactory,
globalSettings.BaseServiceUri.InternalNotifications,

View File

@@ -1,5 +1,4 @@
#nullable enable
using Bit.Core.Context;
using Bit.Core.Context;
using Bit.Core.Enums;
using Bit.Core.IdentityServer;
using Bit.Core.Models;
@@ -19,18 +18,18 @@ namespace Bit.Core.Platform.Push.Internal;
/// Used by Self-Hosted environments.
/// Received by PushController endpoint in Api project.
/// </summary>
public class RelayPushNotificationService : BaseIdentityClientService, IPushEngine
public class RelayPushEngine : BaseIdentityClientService, IPushEngine
{
private readonly IDeviceRepository _deviceRepository;
private readonly IHttpContextAccessor _httpContextAccessor;
public RelayPushNotificationService(
public RelayPushEngine(
IHttpClientFactory httpFactory,
IDeviceRepository deviceRepository,
GlobalSettings globalSettings,
IHttpContextAccessor httpContextAccessor,
ILogger<RelayPushNotificationService> logger)
ILogger<RelayPushEngine> logger)
: base(
httpFactory,
globalSettings.PushRelayBaseUri,

View File

@@ -1,8 +1,7 @@
#nullable enable
using Bit.Core.Enums;
using Bit.Core.Enums;
using Bit.Core.Vault.Entities;
namespace Bit.Core.Platform.Push;
namespace Bit.Core.Platform.Push.Internal;
public interface IPushEngine
{

View File

@@ -1,5 +1,4 @@
#nullable enable
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Auth.Entities;
using Bit.Core.Enums;
using Bit.Core.Models;
@@ -10,10 +9,27 @@ using Microsoft.Extensions.Logging;
namespace Bit.Core.Platform.Push;
/// <summary>
/// Used to Push notifications to end-user devices.
/// </summary>
/// <remarks>
/// New notifications should not be wired up inside this service. You may either directly call the
/// <see cref="PushAsync"/> method in your service to send your notification or if you want your notification
/// sent by other teams you can make an extension method on this service with a well typed definition
/// of your notification. You may also make your own service that injects this and exposes methods for each of
/// your notifications.
/// </remarks>
public interface IPushNotificationService
{
private const string ServiceDeprecation = "Do not use the services exposed here, instead use your own services injected in your service.";
[Obsolete(ServiceDeprecation, DiagnosticId = "BWP0001")]
Guid InstallationId { get; }
[Obsolete(ServiceDeprecation, DiagnosticId = "BWP0001")]
TimeProvider TimeProvider { get; }
[Obsolete(ServiceDeprecation, DiagnosticId = "BWP0001")]
ILogger Logger { get; }
#region Legacy method, to be removed soon.
@@ -80,7 +96,9 @@ public interface IPushNotificationService
Payload = new UserPushNotification
{
UserId = userId,
#pragma warning disable BWP0001 // Type or member is obsolete
Date = TimeProvider.GetUtcNow().UtcDateTime,
#pragma warning restore BWP0001 // Type or member is obsolete
},
ExcludeCurrentContext = false,
});
@@ -94,7 +112,9 @@ public interface IPushNotificationService
Payload = new UserPushNotification
{
UserId = userId,
#pragma warning disable BWP0001 // Type or member is obsolete
Date = TimeProvider.GetUtcNow().UtcDateTime,
#pragma warning restore BWP0001 // Type or member is obsolete
},
ExcludeCurrentContext = false,
});
@@ -108,7 +128,9 @@ public interface IPushNotificationService
Payload = new UserPushNotification
{
UserId = userId,
#pragma warning disable BWP0001 // Type or member is obsolete
Date = TimeProvider.GetUtcNow().UtcDateTime,
#pragma warning restore BWP0001 // Type or member is obsolete
},
ExcludeCurrentContext = false,
});
@@ -122,7 +144,9 @@ public interface IPushNotificationService
Payload = new UserPushNotification
{
UserId = userId,
#pragma warning disable BWP0001 // Type or member is obsolete
Date = TimeProvider.GetUtcNow().UtcDateTime,
#pragma warning restore BWP0001 // Type or member is obsolete
},
ExcludeCurrentContext = false,
});
@@ -136,7 +160,9 @@ public interface IPushNotificationService
Payload = new UserPushNotification
{
UserId = userId,
#pragma warning disable BWP0001 // Type or member is obsolete
Date = TimeProvider.GetUtcNow().UtcDateTime,
#pragma warning restore BWP0001 // Type or member is obsolete
},
ExcludeCurrentContext = false,
});
@@ -150,7 +176,9 @@ public interface IPushNotificationService
Payload = new UserPushNotification
{
UserId = userId,
#pragma warning disable BWP0001 // Type or member is obsolete
Date = TimeProvider.GetUtcNow().UtcDateTime,
#pragma warning restore BWP0001 // Type or member is obsolete
},
ExcludeCurrentContext = excludeCurrentContextFromPush,
});
@@ -231,7 +259,9 @@ public interface IPushNotificationService
ClientType = notification.ClientType,
UserId = notification.UserId,
OrganizationId = notification.OrganizationId,
#pragma warning disable BWP0001 // Type or member is obsolete
InstallationId = notification.Global ? InstallationId : null,
#pragma warning restore BWP0001 // Type or member is obsolete
TaskId = notification.TaskId,
Title = notification.Title,
Body = notification.Body,
@@ -246,7 +276,9 @@ public interface IPushNotificationService
{
// TODO: Think about this a bit more
target = NotificationTarget.Installation;
#pragma warning disable BWP0001 // Type or member is obsolete
targetId = InstallationId;
#pragma warning restore BWP0001 // Type or member is obsolete
}
else if (notification.UserId.HasValue)
{
@@ -260,7 +292,9 @@ public interface IPushNotificationService
}
else
{
#pragma warning disable BWP0001 // Type or member is obsolete
Logger.LogWarning("Invalid notification id {NotificationId} push notification", notification.Id);
#pragma warning restore BWP0001 // Type or member is obsolete
return Task.CompletedTask;
}
@@ -285,7 +319,9 @@ public interface IPushNotificationService
ClientType = notification.ClientType,
UserId = notification.UserId,
OrganizationId = notification.OrganizationId,
#pragma warning disable BWP0001 // Type or member is obsolete
InstallationId = notification.Global ? InstallationId : null,
#pragma warning restore BWP0001 // Type or member is obsolete
TaskId = notification.TaskId,
Title = notification.Title,
Body = notification.Body,
@@ -302,7 +338,9 @@ public interface IPushNotificationService
{
// TODO: Think about this a bit more
target = NotificationTarget.Installation;
#pragma warning disable BWP0001 // Type or member is obsolete
targetId = InstallationId;
#pragma warning restore BWP0001 // Type or member is obsolete
}
else if (notification.UserId.HasValue)
{
@@ -316,7 +354,9 @@ public interface IPushNotificationService
}
else
{
#pragma warning disable BWP0001 // Type or member is obsolete
Logger.LogWarning("Invalid notification status id {NotificationId} push notification", notification.Id);
#pragma warning restore BWP0001 // Type or member is obsolete
return Task.CompletedTask;
}
@@ -398,7 +438,9 @@ public interface IPushNotificationService
Payload = new UserPushNotification
{
UserId = userId,
#pragma warning disable BWP0001 // Type or member is obsolete
Date = TimeProvider.GetUtcNow().UtcDateTime,
#pragma warning restore BWP0001 // Type or member is obsolete
},
ExcludeCurrentContext = false,
});
@@ -406,6 +448,12 @@ public interface IPushNotificationService
Task PushCipherAsync(Cipher cipher, PushType pushType, IEnumerable<Guid>? collectionIds);
/// <summary>
/// Pushes a notification to devices based on the settings given to us in <see cref="PushNotification{T}"/>.
/// </summary>
/// <typeparam name="T">The type of the payload to be sent along with the notification.</typeparam>
/// <param name="pushNotification"></param>
/// <returns>A task that is NOT guarunteed to have sent the notification by the time the task resolves.</returns>
Task PushAsync<T>(PushNotification<T> pushNotification)
where T : class;
}

View File

@@ -1,6 +1,4 @@
#nullable enable
using System.Text.Json;
using System.Text.Json;
using Bit.Core.Enums;
namespace Bit.Core.Platform.Push.Internal;

View File

@@ -0,0 +1,8 @@
using Microsoft.Azure.NotificationHubs;
namespace Bit.Core.Platform.Push.Internal;
public interface INotificationHubProxy
{
Task<(INotificationHubClient Client, NotificationOutcome Outcome)[]> SendTemplateNotificationAsync(IDictionary<string, string> properties, string tagExpression);
}

View File

@@ -0,0 +1,10 @@
using Microsoft.Azure.NotificationHubs;
namespace Bit.Core.Platform.Push.Internal;
public interface INotificationHubPool
{
NotificationHubConnection ConnectionFor(Guid comb);
INotificationHubClient ClientFor(Guid comb);
INotificationHubProxy AllClients { get; }
}

View File

@@ -0,0 +1,26 @@
using Microsoft.Azure.NotificationHubs;
namespace Bit.Core.Platform.Push.Internal;
public class NotificationHubClientProxy : INotificationHubProxy
{
private readonly IEnumerable<INotificationHubClient> _clients;
public NotificationHubClientProxy(IEnumerable<INotificationHubClient> clients)
{
_clients = clients;
}
private async Task<(INotificationHubClient, T)[]> ApplyToAllClientsAsync<T>(Func<INotificationHubClient, Task<T>> action)
{
var tasks = _clients.Select(async c => (c, await action(c)));
return await Task.WhenAll(tasks);
}
// partial proxy of INotificationHubClient implementation
// Note: Any other methods that are needed can simply be delegated as done here.
public async Task<(INotificationHubClient Client, NotificationOutcome Outcome)[]> SendTemplateNotificationAsync(IDictionary<string, string> properties, string tagExpression)
{
return await ApplyToAllClientsAsync(async c => await c.SendTemplateNotificationAsync(properties, tagExpression));
}
}

View File

@@ -0,0 +1,170 @@
using System.Diagnostics.CodeAnalysis;
using System.Security.Cryptography;
using System.Text;
using System.Web;
using Bit.Core.Settings;
using Bit.Core.Utilities;
using Microsoft.Azure.NotificationHubs;
namespace Bit.Core.Platform.Push.Internal;
public class NotificationHubConnection
{
public string? HubName { get; init; }
public string? ConnectionString { get; init; }
private Lazy<NotificationHubConnectionStringBuilder> _parsedConnectionString;
public Uri Endpoint => _parsedConnectionString.Value.Endpoint;
private string SasKey => _parsedConnectionString.Value.SharedAccessKey;
private string SasKeyName => _parsedConnectionString.Value.SharedAccessKeyName;
public bool EnableSendTracing { get; init; }
private NotificationHubClient? _hubClient;
/// <summary>
/// Gets the NotificationHubClient for this connection.
///
/// If the client is null, it will be initialized.
///
/// <throws>Exception</throws> if the connection is invalid.
/// </summary>
public NotificationHubClient HubClient
{
get
{
if (_hubClient == null)
{
if (!IsValid)
{
throw new Exception("Invalid notification hub settings");
}
Init();
}
return _hubClient;
}
private set
{
_hubClient = value;
}
}
/// <summary>
/// Gets the start date for registration.
///
/// If null, registration is always disabled.
/// </summary>
public DateTime? RegistrationStartDate { get; init; }
/// <summary>
/// Gets the end date for registration.
///
/// If null, registration has no end date.
/// </summary>
public DateTime? RegistrationEndDate { get; init; }
/// <summary>
/// Gets whether all data needed to generate a connection to Notification Hub is present.
/// </summary>
public bool IsValid
{
get
{
{
var invalid = string.IsNullOrWhiteSpace(HubName) || string.IsNullOrWhiteSpace(ConnectionString);
return !invalid;
}
}
}
public string LogString
{
get
{
return $"HubName: {HubName}, EnableSendTracing: {EnableSendTracing}, RegistrationStartDate: {RegistrationStartDate}, RegistrationEndDate: {RegistrationEndDate}";
}
}
/// <summary>
/// Gets whether registration is enabled for the given comb ID.
/// This is based off of the generation time encoded in the comb ID.
/// </summary>
/// <param name="comb"></param>
/// <returns></returns>
public bool RegistrationEnabled(Guid comb)
{
var combTime = CoreHelpers.DateFromComb(comb);
return RegistrationEnabled(combTime);
}
/// <summary>
/// Gets whether registration is enabled for the given time.
/// </summary>
/// <param name="queryTime">The time to check</param>
/// <returns></returns>
public bool RegistrationEnabled(DateTime queryTime)
{
if (queryTime >= RegistrationEndDate || RegistrationStartDate == null)
{
return false;
}
return RegistrationStartDate < queryTime;
}
public HttpRequestMessage CreateRequest(HttpMethod method, string pathUri, params string[] queryParameters)
{
var uriBuilder = new UriBuilder(Endpoint)
{
Scheme = "https",
Path = $"{HubName}/{pathUri.TrimStart('/')}",
Query = string.Join('&', [.. queryParameters, "api-version=2015-01"]),
};
var result = new HttpRequestMessage(method, uriBuilder.Uri);
result.Headers.Add("Authorization", GenerateSasToken(uriBuilder.Uri));
result.Headers.Add("TrackingId", Guid.NewGuid().ToString());
return result;
}
private string GenerateSasToken(Uri uri)
{
string targetUri = Uri.EscapeDataString(uri.ToString().ToLower()).ToLower();
long expires = DateTime.UtcNow.AddMinutes(1).Ticks / TimeSpan.TicksPerSecond;
string stringToSign = targetUri + "\n" + expires;
using (var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(SasKey)))
{
var signature = Convert.ToBase64String(hmac.ComputeHash(Encoding.UTF8.GetBytes(stringToSign)));
return $"SharedAccessSignature sr={targetUri}&sig={HttpUtility.UrlEncode(signature)}&se={expires}&skn={SasKeyName}";
}
}
private NotificationHubConnection()
{
_parsedConnectionString = new(() => new NotificationHubConnectionStringBuilder(ConnectionString));
}
/// <summary>
/// Creates a new NotificationHubConnection from the given settings.
/// </summary>
/// <param name="settings"></param>
/// <returns></returns>
public static NotificationHubConnection From(GlobalSettings.NotificationHubSettings settings)
{
return new()
{
HubName = settings.HubName,
ConnectionString = settings.ConnectionString,
EnableSendTracing = settings.EnableSendTracing,
// Comb time is not precise enough for millisecond accuracy
RegistrationStartDate = settings.RegistrationStartDate.HasValue ? Truncate(settings.RegistrationStartDate.Value, TimeSpan.FromMilliseconds(10)) : null,
RegistrationEndDate = settings.RegistrationEndDate
};
}
[MemberNotNull(nameof(_hubClient))]
private NotificationHubConnection Init()
{
_hubClient = NotificationHubClient.CreateClientFromConnectionString(ConnectionString, HubName, EnableSendTracing);
return this;
}
private static DateTime Truncate(DateTime dateTime, TimeSpan resolution)
{
return dateTime.AddTicks(-(dateTime.Ticks % resolution.Ticks));
}
}

View File

@@ -0,0 +1,75 @@
using Bit.Core.Settings;
using Bit.Core.Utilities;
using Microsoft.Azure.NotificationHubs;
using Microsoft.Extensions.Logging;
namespace Bit.Core.Platform.Push.Internal;
public class NotificationHubPool : INotificationHubPool
{
private List<NotificationHubConnection> _connections { get; }
private readonly IEnumerable<INotificationHubClient> _clients;
private readonly ILogger<NotificationHubPool> _logger;
public NotificationHubPool(ILogger<NotificationHubPool> logger, GlobalSettings globalSettings)
{
_logger = logger;
_connections = FilterInvalidHubs(globalSettings.NotificationHubPool.NotificationHubs);
_clients = _connections.GroupBy(c => c.ConnectionString).Select(g => g.First().HubClient);
}
private List<NotificationHubConnection> FilterInvalidHubs(IEnumerable<GlobalSettings.NotificationHubSettings> hubs)
{
List<NotificationHubConnection> result = new();
_logger.LogDebug("Filtering {HubCount} notification hubs", hubs.Count());
foreach (var hub in hubs)
{
var connection = NotificationHubConnection.From(hub);
if (!connection.IsValid)
{
_logger.LogWarning("Invalid notification hub settings: {HubName}", hub.HubName ?? "hub name missing");
continue;
}
_logger.LogDebug("Adding notification hub: {ConnectionLogString}", connection.LogString);
result.Add(connection);
}
return result;
}
/// <summary>
/// Gets the NotificationHubClient for the given comb ID.
/// </summary>
/// <param name="comb"></param>
/// <returns></returns>
/// <exception cref="InvalidOperationException">Thrown when no notification hub is found for a given comb.</exception>
public INotificationHubClient ClientFor(Guid comb)
{
var resolvedConnection = ConnectionFor(comb);
return resolvedConnection.HubClient;
}
/// <summary>
/// Gets the NotificationHubConnection for the given comb ID.
/// </summary>
/// <param name="comb"></param>
/// <returns></returns>
/// <exception cref="InvalidOperationException">Thrown when no notification hub is found for a given comb.</exception>
public NotificationHubConnection ConnectionFor(Guid comb)
{
var possibleConnections = _connections.Where(c => c.RegistrationEnabled(comb)).ToArray();
if (possibleConnections.Length == 0)
{
throw new InvalidOperationException($"No valid notification hubs are available for the given comb ({comb}).\n" +
$"The comb's datetime is {CoreHelpers.DateFromComb(comb)}." +
$"Hub start and end times are configured as follows:\n" +
string.Join("\n", _connections.Select(c => $"Hub {c.HubName} - Start: {c.RegistrationStartDate}, End: {c.RegistrationEndDate}")));
}
var resolvedConnection = possibleConnections[CoreHelpers.BinForComb(comb, possibleConnections.Length)];
_logger.LogTrace("Resolved notification hub for comb {Comb} out of {HubCount} hubs.\n{ConnectionInfo}", comb, possibleConnections.Length, resolvedConnection.LogString);
return resolvedConnection;
}
public INotificationHubProxy AllClients { get { return new NotificationHubClientProxy(_clients); } }
}

View File

@@ -0,0 +1,194 @@
using System.Text.Json;
using System.Text.RegularExpressions;
using Bit.Core.Context;
using Bit.Core.Enums;
using Bit.Core.Models;
using Bit.Core.Models.Data;
using Bit.Core.Repositories;
using Bit.Core.Settings;
using Bit.Core.Vault.Entities;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
namespace Bit.Core.Platform.Push.Internal;
/// <summary>
/// Sends mobile push notifications to the Azure Notification Hub.
/// Used by Cloud-Hosted environments.
/// Received by Firebase for Android or APNS for iOS.
/// </summary>
public class NotificationHubPushEngine : IPushEngine, IPushRelayer
{
private readonly IInstallationDeviceRepository _installationDeviceRepository;
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly bool _enableTracing = false;
private readonly INotificationHubPool _notificationHubPool;
private readonly ILogger _logger;
public NotificationHubPushEngine(
IInstallationDeviceRepository installationDeviceRepository,
INotificationHubPool notificationHubPool,
IHttpContextAccessor httpContextAccessor,
ILogger<NotificationHubPushEngine> logger,
IGlobalSettings globalSettings)
{
_installationDeviceRepository = installationDeviceRepository;
_httpContextAccessor = httpContextAccessor;
_notificationHubPool = notificationHubPool;
_logger = logger;
if (globalSettings.Installation.Id == Guid.Empty)
{
logger.LogWarning("Installation ID is not set. Push notifications for installations will not work.");
}
}
public async Task PushCipherAsync(Cipher cipher, PushType type, IEnumerable<Guid>? collectionIds)
{
if (cipher.OrganizationId.HasValue)
{
// We cannot send org pushes since access logic is much more complicated than just the fact that they belong
// to the organization. Potentially we could blindly send to just users that have the access all permission
// device registration needs to be more granular to handle that appropriately. A more brute force approach could
// me to send "full sync" push to all org users, but that has the potential to DDOS the API in bursts.
// await SendPayloadToOrganizationAsync(cipher.OrganizationId.Value, type, message, true);
}
else if (cipher.UserId.HasValue)
{
var message = new SyncCipherPushNotification
{
Id = cipher.Id,
UserId = cipher.UserId,
OrganizationId = cipher.OrganizationId,
RevisionDate = cipher.RevisionDate,
CollectionIds = collectionIds,
};
await PushAsync(new PushNotification<SyncCipherPushNotification>
{
Type = type,
Target = NotificationTarget.User,
TargetId = cipher.UserId.Value,
Payload = message,
ExcludeCurrentContext = true,
});
}
}
private string? GetContextIdentifier(bool excludeCurrentContext)
{
if (!excludeCurrentContext)
{
return null;
}
var currentContext =
_httpContextAccessor.HttpContext?.RequestServices.GetService(typeof(ICurrentContext)) as ICurrentContext;
return currentContext?.DeviceIdentifier;
}
private string BuildTag(string tag, string? identifier, ClientType? clientType)
{
if (!string.IsNullOrWhiteSpace(identifier))
{
tag += $" && !deviceIdentifier:{SanitizeTagInput(identifier)}";
}
if (clientType.HasValue && clientType.Value != ClientType.All)
{
tag += $" && clientType:{clientType}";
}
return $"({tag})";
}
public async Task PushAsync<T>(PushNotification<T> pushNotification)
where T : class
{
var initialTag = pushNotification.Target switch
{
NotificationTarget.User => $"template:payload_userId:{pushNotification.TargetId}",
NotificationTarget.Organization => $"template:payload && organizationId:{pushNotification.TargetId}",
NotificationTarget.Installation => $"template:payload && installationId:{pushNotification.TargetId}",
_ => throw new InvalidOperationException($"Push notification target '{pushNotification.Target}' is not valid."),
};
await PushCoreAsync(
initialTag,
GetContextIdentifier(pushNotification.ExcludeCurrentContext),
pushNotification.Type,
pushNotification.ClientType,
pushNotification.Payload
);
}
public async Task RelayAsync(Guid fromInstallation, RelayedNotification relayedNotification)
{
// Relayed notifications need identifiers prefixed with the installation they are from and a underscore
var initialTag = relayedNotification.Target switch
{
NotificationTarget.User => $"template:payload_userId:{fromInstallation}_{relayedNotification.TargetId}",
NotificationTarget.Organization => $"template:payload && organizationId:{fromInstallation}_{relayedNotification.TargetId}",
NotificationTarget.Installation => $"template:payload && installationId:{fromInstallation}",
_ => throw new InvalidOperationException($"Invalid Notification target {relayedNotification.Target}"),
};
await PushCoreAsync(
initialTag,
relayedNotification.Identifier,
relayedNotification.Type,
relayedNotification.ClientType,
relayedNotification.Payload
);
if (relayedNotification.DeviceId.HasValue)
{
await _installationDeviceRepository.UpsertAsync(
new InstallationDeviceEntity(fromInstallation, relayedNotification.DeviceId.Value)
);
}
else
{
_logger.LogWarning(
"A related notification of type '{Type}' came through without a device id from installation {Installation}",
relayedNotification.Type,
fromInstallation
);
}
}
private async Task PushCoreAsync<T>(string initialTag, string? contextId, PushType pushType, ClientType? clientType, T payload)
{
var finalTag = BuildTag(initialTag, contextId, clientType);
var results = await _notificationHubPool.AllClients.SendTemplateNotificationAsync(
new Dictionary<string, string>
{
{ "type", ((byte)pushType).ToString() },
{ "payload", JsonSerializer.Serialize(payload) },
},
finalTag
);
if (_enableTracing)
{
foreach (var (client, outcome) in results)
{
if (!client.EnableTestSend)
{
continue;
}
_logger.LogInformation(
"Azure Notification Hub Tracking ID: {Id} | {Type} push notification with {Success} successes and {Failure} failures with a payload of {@Payload} and result of {@Results}",
outcome.TrackingId, pushType, outcome.Success, outcome.Failure, payload, outcome.Results);
}
}
}
private string SanitizeTagInput(string input)
{
// Only allow a-z, A-Z, 0-9, and special characters -_:
return Regex.Replace(input, "[^a-zA-Z0-9-_:]", string.Empty);
}
}

View File

@@ -0,0 +1,44 @@
using Bit.Core.Enums;
namespace Bit.Core.Platform.Push;
/// <summary>
/// Used to annotate information about a given <see cref="PushType"/>.
/// </summary>
[AttributeUsage(AttributeTargets.Field)]
public class NotificationInfoAttribute : Attribute
{
// Once upon a time we can feed this information into a C# analyzer to make sure that we validate
// the callsites of IPushNotificationService.PushAsync uses the correct payload type for the notification type
// for now this only exists as forced documentation to teams who create a push type.
// It's especially on purpose that we allow ourselves to take a type name via just the string,
// this allows teams to make a push type that is only sent with a payload that exists in a separate assembly than
// this one.
public NotificationInfoAttribute(string team, Type payloadType)
// It should be impossible to reference an unnamed type for an attributes constructor so this assertion should be safe.
: this(team, payloadType.FullName!)
{
Team = team;
}
public NotificationInfoAttribute(string team, string payloadTypeName)
{
ArgumentException.ThrowIfNullOrWhiteSpace(team);
ArgumentException.ThrowIfNullOrWhiteSpace(payloadTypeName);
Team = team;
PayloadTypeName = payloadTypeName;
}
/// <summary>
/// The name of the team that owns this <see cref="PushType"/>.
/// </summary>
public string Team { get; }
/// <summary>
/// The fully qualified type name of the payload that should be used when sending a notification of this type.
/// </summary>
public string PayloadTypeName { get; }
}

View File

@@ -6,6 +6,9 @@ namespace Bit.Core.Platform.Push;
/// <summary>
/// Contains constants for all the available targets for a given notification.
/// </summary>
/// <remarks>
/// Please reach out to the Platform team if you need a new target added.
/// </remarks>
public enum NotificationTarget
{
/// <summary>

View File

@@ -0,0 +1,82 @@
using Azure.Storage.Queues;
using Bit.Core.Platform.Push;
using Bit.Core.Platform.Push.Internal;
using Bit.Core.Settings;
using Bit.Core.Utilities;
using Microsoft.Extensions.DependencyInjection.Extensions;
namespace Microsoft.Extensions.DependencyInjection;
/// <summary>
/// Extension methods for adding the Push feature.
/// </summary>
public static class PushServiceCollectionExtensions
{
/// <summary>
/// Adds a <see cref="IPushNotificationService"/> to the services that can be used to send push notifications to
/// end user devices. This method is safe to be ran multiple time provided <see cref="GlobalSettings"/> does not
/// change between calls.
/// </summary>
/// <param name="services">The <see cref="IServiceCollection"/> to add services to.</param>
/// <param name="globalSettings">The <see cref="GlobalSettings"/> to use to configure services.</param>
/// <returns>The <see cref="IServiceCollection"/> for additional chaining.</returns>
public static IServiceCollection AddPush(this IServiceCollection services, GlobalSettings globalSettings)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(globalSettings);
services.TryAddSingleton(TimeProvider.System);
services.TryAddSingleton<IPushNotificationService, MultiServicePushNotificationService>();
if (globalSettings.SelfHosted)
{
if (globalSettings.Installation.Id == Guid.Empty)
{
throw new InvalidOperationException("Installation Id must be set for self-hosted installations.");
}
if (CoreHelpers.SettingHasValue(globalSettings.PushRelayBaseUri) &&
CoreHelpers.SettingHasValue(globalSettings.Installation.Key))
{
// TODO: We should really define the HttpClient we will use here
services.AddHttpClient();
services.AddHttpContextAccessor();
// We also depend on IDeviceRepository but don't explicitly add it right now.
services.TryAddEnumerable(ServiceDescriptor.Singleton<IPushEngine, RelayPushEngine>());
}
if (CoreHelpers.SettingHasValue(globalSettings.InternalIdentityKey) &&
CoreHelpers.SettingHasValue(globalSettings.BaseServiceUri.InternalNotifications))
{
// TODO: We should really define the HttpClient we will use here
services.AddHttpClient();
services.AddHttpContextAccessor();
services.TryAddEnumerable(ServiceDescriptor.Singleton<IPushEngine, NotificationsApiPushEngine>());
}
}
else
{
services.TryAddSingleton<INotificationHubPool, NotificationHubPool>();
services.AddHttpContextAccessor();
// We also depend on IInstallationDeviceRepository but don't explicitly add it right now.
services.TryAddEnumerable(ServiceDescriptor.Singleton<IPushEngine, NotificationHubPushEngine>());
services.TryAddSingleton<IPushRelayer, NotificationHubPushEngine>();
if (CoreHelpers.SettingHasValue(globalSettings.Notifications?.ConnectionString))
{
services.TryAddKeyedSingleton("notifications", static (sp, _) =>
{
var gs = sp.GetRequiredService<GlobalSettings>();
return new QueueClient(gs.Notifications.ConnectionString, "notifications");
});
// We not IHttpContextAccessor will be added above, no need to do it here.
services.TryAddEnumerable(ServiceDescriptor.Singleton<IPushEngine, AzureQueuePushEngine>());
}
}
return services;
}
}

View File

@@ -0,0 +1,93 @@
using Bit.Core.Platform.Push;
// TODO: This namespace should change to `Bit.Core.Platform.Push`
namespace Bit.Core.Enums;
/// <summary>
///
/// </summary>
/// <remarks>
/// <para>
/// When adding a new enum member you must annotate it with a <see cref="NotificationInfoAttribute"/>
/// this is enforced with a unit test. It is preferred that you do NOT add new usings for the type referenced
/// in <see cref="NotificationInfoAttribute"/>.
/// </para>
/// <para>
/// You may and are
/// </para>
/// </remarks>
public enum PushType : byte
{
// When adding a new enum member you must annotate it with a NotificationInfoAttribute this is enforced with a unit
// test. It is preferred that you do NOT add new usings for the type referenced for the payload. You are also
// encouraged to define the payload type in your own teams owned code.
[NotificationInfo("@bitwarden/team-vault-dev", typeof(Models.SyncCipherPushNotification))]
SyncCipherUpdate = 0,
[NotificationInfo("@bitwarden/team-vault-dev", typeof(Models.SyncCipherPushNotification))]
SyncCipherCreate = 1,
[NotificationInfo("@bitwarden/team-vault-dev", typeof(Models.SyncCipherPushNotification))]
SyncLoginDelete = 2,
[NotificationInfo("@bitwarden/team-vault-dev", typeof(Models.SyncFolderPushNotification))]
SyncFolderDelete = 3,
[NotificationInfo("@bitwarden/team-vault-dev", typeof(Models.UserPushNotification))]
SyncCiphers = 4,
[NotificationInfo("not-specified", typeof(Models.UserPushNotification))]
SyncVault = 5,
[NotificationInfo("@bitwarden/team-admin-console-dev", typeof(Models.UserPushNotification))]
SyncOrgKeys = 6,
[NotificationInfo("@bitwarden/team-vault-dev", typeof(Models.SyncFolderPushNotification))]
SyncFolderCreate = 7,
[NotificationInfo("@bitwarden/team-vault-dev", typeof(Models.SyncFolderPushNotification))]
SyncFolderUpdate = 8,
[NotificationInfo("@bitwarden/team-vault-dev", typeof(Models.SyncCipherPushNotification))]
SyncCipherDelete = 9,
[NotificationInfo("not-specified", typeof(Models.UserPushNotification))]
SyncSettings = 10,
[NotificationInfo("not-specified", typeof(Models.UserPushNotification))]
LogOut = 11,
[NotificationInfo("@bitwarden/team-tools-dev", typeof(Models.SyncSendPushNotification))]
SyncSendCreate = 12,
[NotificationInfo("@bitwarden/team-tools-dev", typeof(Models.SyncSendPushNotification))]
SyncSendUpdate = 13,
[NotificationInfo("@bitwarden/team-tools-dev", typeof(Models.SyncSendPushNotification))]
SyncSendDelete = 14,
[NotificationInfo("@bitwarden/team-auth-dev", typeof(Models.AuthRequestPushNotification))]
AuthRequest = 15,
[NotificationInfo("@bitwarden/team-auth-dev", typeof(Models.AuthRequestPushNotification))]
AuthRequestResponse = 16,
[NotificationInfo("not-specified", typeof(Models.UserPushNotification))]
SyncOrganizations = 17,
[NotificationInfo("@bitwarden/team-billing-dev", typeof(Models.OrganizationStatusPushNotification))]
SyncOrganizationStatusChanged = 18,
[NotificationInfo("@bitwarden/team-admin-console-dev", typeof(Models.OrganizationCollectionManagementPushNotification))]
SyncOrganizationCollectionSettingChanged = 19,
[NotificationInfo("not-specified", typeof(Models.NotificationPushNotification))]
Notification = 20,
[NotificationInfo("not-specified", typeof(Models.NotificationPushNotification))]
NotificationStatus = 21,
[NotificationInfo("@bitwarden/team-vault-dev", typeof(Models.UserPushNotification))]
RefreshSecurityTasks = 22,
}

View File

@@ -1,10 +1,10 @@
#nullable enable
using Bit.Core.Enums;
using Bit.Core.NotificationHub;
using Bit.Core.Enums;
using Bit.Core.Platform.PushRegistration;
// TODO: Change this namespace to `Bit.Core.Platform.PushRegistration
namespace Bit.Core.Platform.Push;
public interface IPushRegistrationService
{
Task CreateOrUpdateRegistrationAsync(PushRegistrationData data, string deviceId, string userId, string identifier, DeviceType type, IEnumerable<string> organizationIds, Guid installationId);

View File

@@ -1,9 +1,7 @@
#nullable enable
using Bit.Core.Enums;
using Bit.Core.Platform.Push;
using Bit.Core.Enums;
using Bit.Core.NotificationHub;
namespace Bit.Core.Platform.Push.Internal;
namespace Bit.Core.Platform.PushRegistration.Internal;
public class NoopPushRegistrationService : IPushRegistrationService
{

View File

@@ -0,0 +1,325 @@
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.Platform.Push.Internal;
using Bit.Core.Repositories;
using Bit.Core.Utilities;
using Microsoft.Azure.NotificationHubs;
using Microsoft.Extensions.Logging;
namespace Bit.Core.Platform.PushRegistration.Internal;
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<NotificationHubPushRegistrationService> _logger;
public NotificationHubPushRegistrationService(
IInstallationDeviceRepository installationDeviceRepository,
INotificationHubPool notificationHubPool,
IHttpClientFactory httpClientFactory,
ILogger<NotificationHubPushRegistrationService> logger)
{
_installationDeviceRepository = installationDeviceRepository;
_notificationHubPool = notificationHubPool;
_httpClientFactory = httpClientFactory;
_logger = logger;
}
public async Task CreateOrUpdateRegistrationAsync(PushRegistrationData data, string deviceId, string userId,
string? identifier, DeviceType type, IEnumerable<string> organizationIds, Guid installationId)
{
var orgIds = organizationIds.ToList();
var clientType = DeviceTypes.ToClientType(type);
var installation = new Installation
{
InstallationId = deviceId,
PushChannel = data.Token,
Tags = new List<string>
{
$"userId:{userId}",
$"clientType:{clientType}"
}.Concat(orgIds.Select(organizationId => $"organizationId:{organizationId}")).ToList(),
Templates = new Dictionary<string, InstallationTemplate>()
};
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<string> 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<string> 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<string, InstallationTemplate> BuildInstallationTemplate(string templateId, [StringSyntax(StringSyntaxAttribute.Json)] string templateBody,
string userId, string? identifier, ClientType clientType, List<string> organizationIds, Guid installationId)
{
var fullTemplateId = $"template:{templateId}";
var template = new InstallationTemplate
{
Body = templateBody,
Tags = new List<string>
{
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<string, InstallationTemplate>(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<string> 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<string> 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<string> 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<PartialUpdateOperation> { 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;
}
}

View File

@@ -0,0 +1,31 @@
namespace Bit.Core.Platform.PushRegistration;
public record struct WebPushRegistrationData
{
public string Endpoint { get; init; }
public string P256dh { get; init; }
public string Auth { get; init; }
}
public record class PushRegistrationData
{
public string? Token { get; set; }
public WebPushRegistrationData? WebPush { get; set; }
public PushRegistrationData(string? token)
{
Token = token;
}
public PushRegistrationData(string Endpoint, string P256dh, string Auth) : this(new WebPushRegistrationData
{
Endpoint = Endpoint,
P256dh = P256dh,
Auth = Auth
})
{ }
public PushRegistrationData(WebPushRegistrationData webPush)
{
WebPush = webPush;
}
}

View File

@@ -0,0 +1,54 @@
using Bit.Core.Platform.Push;
using Bit.Core.Platform.Push.Internal;
using Bit.Core.Platform.PushRegistration.Internal;
using Bit.Core.Settings;
using Bit.Core.Utilities;
using Microsoft.Extensions.DependencyInjection.Extensions;
namespace Microsoft.Extensions.DependencyInjection;
/// <summary>
/// Extension methods for adding the Push Registration feature.
/// </summary>
public static class PushRegistrationServiceCollectionExtensions
{
/// <summary>
/// Adds a <see cref="IPushRegistrationService"/> to the service collection.
/// </summary>
/// <param name="services">The <see cref="IServiceCollection"/> to add services to.</param>
/// <returns>The <see cref="IServiceCollection"/> for chaining.</returns>
public static IServiceCollection AddPushRegistration(this IServiceCollection services)
{
ArgumentNullException.ThrowIfNull(services);
// TODO: Should add feature that brings in IInstallationDeviceRepository once that is featurized
// Register all possible variants under there concrete type.
services.TryAddSingleton<RelayPushRegistrationService>();
services.TryAddSingleton<NoopPushRegistrationService>();
services.AddHttpClient();
services.TryAddSingleton<INotificationHubPool, NotificationHubPool>();
services.TryAddSingleton<NotificationHubPushRegistrationService>();
services.TryAddSingleton<IPushRegistrationService>(static sp =>
{
var globalSettings = sp.GetRequiredService<GlobalSettings>();
if (globalSettings.SelfHosted)
{
if (CoreHelpers.SettingHasValue(globalSettings.PushRelayBaseUri) &&
CoreHelpers.SettingHasValue(globalSettings.Installation.Key))
{
return sp.GetRequiredService<RelayPushRegistrationService>();
}
return sp.GetRequiredService<NoopPushRegistrationService>();
}
return sp.GetRequiredService<NotificationHubPushRegistrationService>();
});
return services;
}
}

View File

@@ -1,14 +1,12 @@
#nullable enable
using Bit.Core.Enums;
using Bit.Core.Enums;
using Bit.Core.IdentityServer;
using Bit.Core.Models.Api;
using Bit.Core.NotificationHub;
using Bit.Core.Platform.Push;
using Bit.Core.Services;
using Bit.Core.Settings;
using Microsoft.Extensions.Logging;
namespace Bit.Core.Platform.Push.Internal;
namespace Bit.Core.Platform.PushRegistration.Internal;
public class RelayPushRegistrationService : BaseIdentityClientService, IPushRegistrationService
{