diff --git a/AdsPush.sln b/AdsPush.sln index 490cd7f..966266a 100644 --- a/AdsPush.sln +++ b/AdsPush.sln @@ -21,6 +21,21 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AdsPushSample.Api", "sample EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AdsPushSample.ConsoleApp", "samples\AdsPushSample.ConsoleApp\AdsPushSample.ConsoleApp.csproj", "{CE11B712-AD05-42CD-83C4-1183CCABB081}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AdsPush.Vapid", "src\AdsPush.Vapid\AdsPush.Vapid.csproj", "{5EC17E88-BF46-4822-89AE-CC4B401128D0}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "AdsPushSample.VapidClient", "AdsPushSample.VapidClient", "{8D84A155-DF61-4E97-A847-9CD9049E55AE}" + ProjectSection(SolutionItems) = preProject + samples\AdsPushSample.VapidClient\icon-180.png = samples\AdsPushSample.VapidClient\icon-180.png + samples\AdsPushSample.VapidClient\icon.png = samples\AdsPushSample.VapidClient\icon.png + samples\AdsPushSample.VapidClient\index.html = samples\AdsPushSample.VapidClient\index.html + samples\AdsPushSample.VapidClient\manifest.json = samples\AdsPushSample.VapidClient\manifest.json + samples\AdsPushSample.VapidClient\sample-subscription.json = samples\AdsPushSample.VapidClient\sample-subscription.json + samples\AdsPushSample.VapidClient\service-worker.js = samples\AdsPushSample.VapidClient\service-worker.js + samples\AdsPushSample.VapidClient\splash-image.jpg = samples\AdsPushSample.VapidClient\splash-image.jpg + samples\AdsPushSample.VapidClient\splash.html = samples\AdsPushSample.VapidClient\splash.html + samples\AdsPushSample.VapidClient\splash.css = samples\AdsPushSample.VapidClient\splash.css + EndProjectSection +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -54,6 +69,10 @@ Global {CE11B712-AD05-42CD-83C4-1183CCABB081}.Debug|Any CPU.Build.0 = Debug|Any CPU {CE11B712-AD05-42CD-83C4-1183CCABB081}.Release|Any CPU.ActiveCfg = Release|Any CPU {CE11B712-AD05-42CD-83C4-1183CCABB081}.Release|Any CPU.Build.0 = Release|Any CPU + {5EC17E88-BF46-4822-89AE-CC4B401128D0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5EC17E88-BF46-4822-89AE-CC4B401128D0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5EC17E88-BF46-4822-89AE-CC4B401128D0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5EC17E88-BF46-4822-89AE-CC4B401128D0}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {33EC0E05-DEC5-499C-9B58-3648A09C03A7} = {6DCD587C-FB77-4254-B98C-8E3CBC508EF1} @@ -62,5 +81,7 @@ Global {95D40230-2B98-474B-8525-1C4B7BBB7337} = {6DCD587C-FB77-4254-B98C-8E3CBC508EF1} {0C9EC007-C443-415C-B7DA-D0959E4B3292} = {27B12BE4-A809-42C0-A6DC-8306BB2206BF} {CE11B712-AD05-42CD-83C4-1183CCABB081} = {27B12BE4-A809-42C0-A6DC-8306BB2206BF} + {5EC17E88-BF46-4822-89AE-CC4B401128D0} = {6DCD587C-FB77-4254-B98C-8E3CBC508EF1} + {8D84A155-DF61-4E97-A847-9CD9049E55AE} = {27B12BE4-A809-42C0-A6DC-8306BB2206BF} EndGlobalSection EndGlobal diff --git a/README-NUGET.md b/README-NUGET.md index c5db7bb..bf10b35 100644 --- a/README-NUGET.md +++ b/README-NUGET.md @@ -90,6 +90,7 @@ using AdsPush.Extensions; } ``` + And put the following section in your in your `appsettings.[ENV].json` ``` @@ -101,7 +102,8 @@ And put the following section in your in your `appsettings.[ENV].json` "MyApp": { "TargetMappings": { "Ios": "Apns", - "Android": "FirebaseCloudMessaging" + "Android": "FirebaseCloudMessaging", + "BrowserAndPwa": "VapidWebPush" }, "Apns": { "P8PrivateKey": "", @@ -121,20 +123,26 @@ And put the following section in your in your `appsettings.[ENV].json` "AuthProviderX509CertUrl": "", "TokenUri": "", "ClientX509CertUrl": "" + }, + "Vapid": { + "PublicKey": "", + "PrivateKey": "", + "Subject": "" } } } ... } ``` + If you wish to use host/pod environment or any secret provider you can set the following environment variables. ``` -AdsPush__MyApp__Apns__AppBundleIdentifier= -AdsPush__MyApp__Apns__EnvironmentType= -AdsPush__MyApp__Apns__P8PrivateKey= -AdsPush__MyApp__Apns__P8PrivateKeyId=<10 digit p8 certificate id. Usually a part of a downloadable certificate filename> -AdsPush__MyApp__Apns__TeamId= +AdsPush__MyApp__Apns__AppBundleIdentifier= +AdsPush__MyApp__Apns__EnvironmentType= +AdsPush__MyApp__Apns__P8PrivateKey= +AdsPush__MyApp__Apns__P8PrivateKeyId=<10-digit p8 certificate id; often part of a downloadable certificate filename> +AdsPush__MyApp__Apns__TeamId=<10-digit Apple team id shown on the Apple Developer Membership Page> AdsPush__MyApp__FirebaseCloudMessaging__AuthProviderX509CertUrl= AdsPush__MyApp__FirebaseCloudMessaging__AuthUri= AdsPush__MyApp__FirebaseCloudMessaging__ClientEmail= @@ -146,7 +154,11 @@ AdsPush__MyApp__FirebaseCloudMessaging__ProjectId= AdsPush__MyApp__FirebaseCloudMessaging__Type= AdsPush__MyApp__TargetMappings__Android=FirebaseCloudMessaging +AdsPush__MyApp__TargetMappings__BrowserAndPwa=VapidWebPush AdsPush__MyApp__TargetMappings__Ios=Apns +AdsPush__MyApp__Vapid__PrivateKey= +AdsPush__MyApp__Vapid__PublicKey= +AdsPush__MyApp__Vapid__Subject= ``` @@ -183,9 +195,15 @@ var firebaseSettings = new AdsPushFirebaseSettings() //put your configurations hare. }; +var vapidSettings = new AdsPushVapidSettings() +{ + //put your configurations hare. +}; + var sender = builder .ConfigureApns(apnsSettings, null) .ConfigureFirebase(firebaseSettings, AdsPushTarget.Android) + .ConfigureVapid(vapidSettings, null) .BuildSender(); ``` @@ -196,21 +214,43 @@ When you obtain `IAdsPushSender` instance by using one the methods shown above, ```csharp -await sender.BasicSendAsync( - AdsPushTarget.Ios, - "79eb1b9e623bbca0d2b218f44a18d7b8ef59dac4da5baa9949c3e99a48eb259a", - new () + +var basicPayload = new AdsPushBasicSendPayload() +{ + Title = AdsPushText.CreateUsingString("test"), + Detail = AdsPushText.CreateUsingString("detail"), + Badge = 52, + Sound = "default", + Parameters = new Dictionary() { - Title = AdsPushText.CreateUsingString("test"), - Detail = AdsPushText.CreateUsingString("detail"), - Badge = 52, - Sound = "default", - Parameters = new Dictionary() { - {"pushParam1","value1"}, - {"pushParam2","value2"}, - } - }); + "pushParam1", "value1" + }, + { + "pushParam2", "value2" + }, + } +}; + +var apnDeviceToken = "15f6fdd0f34a7e0f46301a817536f0fb1b2ab05b09b3fae02beba2854a1a2a16"; +//var apnDeviceTokenVapid = "{"endpoint:"...", "keys": {"auth":"...","p256dh":"..."}}"; + +await sender.BasicSendAsync( + AdsPushTarget.Ios, + apnDeviceToken, + basicPayload); + +//For VAPID WebPush with multi parametere +string + endpoint = "https://fcm.googleapis.com/fcm/send/cIo6QJ4MMtQ:APA91bEGHCpZdHaUS7otb5_xU1zNWe6TAqria9phFm7M_9ZIiEyr0vXj3gRHbeIJMYvp2-SAVbgNrVvl7uBvU_VTLpIA0CLBcmqXuuEktGr0U4LVLvwWBibO68spJk7D-lr8R9zPyAXE", + p256dh = "BIjydse4Rij892SJN10xx1qbxDM6GrYXSfg7TGu90CVM1WmlTYzn_79psRqseyWdER969LGLjZmnXIhHPaKTyGE", + auth = "TkLGLzFeUU3C9SJJN6dLAA"; + +var subscription = VapidSubscription.FromParameters(endpoint, p256dh, auth); +await sender.BasicSendAsync( + AdsPushTarget.BrowserAndPwa, + subscription.ToAdsPushToken(), + basicPayload); ``` @@ -268,4 +308,32 @@ var firebaseResult = await sender ImageUrl = "" } }); + + + +//Sample for VAPID WebPush +var vapidResult = await sender + .GetVapidSender() + .SendAsync( + subscription, + new VapidRequest() + { + Title = "", + Badge = "", + Message = "", + Sound = "", + Icon = "", + Image = "", + Language = "", + Silent = false, + Tag = "", + ClickAction = "", + VibratePattern = "", + Data = new Dictionary() + { + {"param1", "value1"} + } + }); + + ``` diff --git a/README.md b/README.md index 30ccd71..9e1de81 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@

AdsPush

-AdsPush is the server-side push notification library that fully supports APNS (Apple Push Notification Service) and FCM (Firebase Cloud Messaging) features and works with the most common platforms. It offers good abstraction, is easy to use, and provides complete support for advanced use cases +AdsPush is the server-side push notification library that fully supports APNS (Apple Push Notification Service), FCM (Firebase Cloud Messaging) and VAPID WebPush features and works with the most common platforms. It offers good abstraction, is easy to use, and provides complete support for advanced use cases
Report Bug or Request Feature @@ -124,7 +124,8 @@ And put the following section in your in your `appsettings.[ENV].json` "MyApp": { "TargetMappings": { "Ios": "Apns", - "Android": "FirebaseCloudMessaging" + "Android": "FirebaseCloudMessaging", + "BrowserAndPwa": "VapidWebPush" }, "Apns": { "P8PrivateKey": "", @@ -144,20 +145,26 @@ And put the following section in your in your `appsettings.[ENV].json` "AuthProviderX509CertUrl": "", "TokenUri": "", "ClientX509CertUrl": "" + }, + "Vapid": { + "PublicKey": "", + "PrivateKey": "", + "Subject": "" } } } ... } ``` + If you wish to use host/pod environment or any secret provider you can set the following environment variables. ``` -AdsPush__MyApp__Apns__AppBundleIdentifier= -AdsPush__MyApp__Apns__EnvironmentType= -AdsPush__MyApp__Apns__P8PrivateKey= -AdsPush__MyApp__Apns__P8PrivateKeyId=<10 digit p8 certificate id. Usually a part of a downloadable certificate filename> -AdsPush__MyApp__Apns__TeamId= +AdsPush__MyApp__Apns__AppBundleIdentifier= +AdsPush__MyApp__Apns__EnvironmentType= +AdsPush__MyApp__Apns__P8PrivateKey= +AdsPush__MyApp__Apns__P8PrivateKeyId=<10-digit p8 certificate id; often part of a downloadable certificate filename> +AdsPush__MyApp__Apns__TeamId=<10-digit Apple team id shown on the Apple Developer Membership Page> AdsPush__MyApp__FirebaseCloudMessaging__AuthProviderX509CertUrl= AdsPush__MyApp__FirebaseCloudMessaging__AuthUri= AdsPush__MyApp__FirebaseCloudMessaging__ClientEmail= @@ -169,7 +176,11 @@ AdsPush__MyApp__FirebaseCloudMessaging__ProjectId= AdsPush__MyApp__FirebaseCloudMessaging__Type= AdsPush__MyApp__TargetMappings__Android=FirebaseCloudMessaging +AdsPush__MyApp__TargetMappings__BrowserAndPwa=VapidWebPush AdsPush__MyApp__TargetMappings__Ios=Apns +AdsPush__MyApp__Vapid__PrivateKey= +AdsPush__MyApp__Vapid__PublicKey= +AdsPush__MyApp__Vapid__Subject= ``` @@ -206,9 +217,15 @@ var firebaseSettings = new AdsPushFirebaseSettings() //put your configurations hare. }; +var vapidSettings = new AdsPushVapidSettings() +{ + //put your configurations hare. +}; + var sender = builder .ConfigureApns(apnsSettings, null) .ConfigureFirebase(firebaseSettings, AdsPushTarget.Android) + .ConfigureVapid(vapidSettings, null) .BuildSender(); ``` @@ -219,21 +236,43 @@ When you obtain `IAdsPushSender` instance by using one the methods shown above, ```csharp -await sender.BasicSendAsync( - AdsPushTarget.Ios, - "79eb1b9e623bbca0d2b218f44a18d7b8ef59dac4da5baa9949c3e99a48eb259a", - new () + +var basicPayload = new AdsPushBasicSendPayload() +{ + Title = AdsPushText.CreateUsingString("test"), + Detail = AdsPushText.CreateUsingString("detail"), + Badge = 52, + Sound = "default", + Parameters = new Dictionary() { - Title = AdsPushText.CreateUsingString("test"), - Detail = AdsPushText.CreateUsingString("detail"), - Badge = 52, - Sound = "default", - Parameters = new Dictionary() { - {"pushParam1","value1"}, - {"pushParam2","value2"}, - } - }); + "pushParam1", "value1" + }, + { + "pushParam2", "value2" + }, + } +}; + +var apnDeviceToken = "15f6fdd0f34a7e0f46301a817536f0fb1b2ab05b09b3fae02beba2854a1a2a16"; +//var apnDeviceTokenVapid = "{"endpoint:"...", "keys": {"auth":"...","p256dh":"..."}}"; + +await sender.BasicSendAsync( + AdsPushTarget.Ios, + apnDeviceToken, + basicPayload); + +//For VAPID WebPush with multi parametere +string + endpoint = "https://fcm.googleapis.com/fcm/send/cIo6QJ4MMtQ:APA91bEGHCpZdHaUS7otb5_xU1zNWe6TAqria9phFm7M_9ZIiEyr0vXj3gRHbeIJMYvp2-SAVbgNrVvl7uBvU_VTLpIA0CLBcmqXuuEktGr0U4LVLvwWBibO68spJk7D-lr8R9zPyAXE", + p256dh = "BIjydse4Rij892SJN10xx1qbxDM6GrYXSfg7TGu90CVM1WmlTYzn_79psRqseyWdER969LGLjZmnXIhHPaKTyGE", + auth = "TkLGLzFeUU3C9SJJN6dLAA"; + +var subscription = VapidSubscription.FromParameters(endpoint, p256dh, auth); +await sender.BasicSendAsync( + AdsPushTarget.BrowserAndPwa, + subscription.ToAdsPushToken(), + basicPayload); ``` @@ -291,6 +330,34 @@ var firebaseResult = await sender ImageUrl = "" } }); + + + +//Sample for VAPID WebPush +var vapidResult = await sender + .GetVapidSender() + .SendAsync( + subscription, + new VapidRequest() + { + Title = "", + Badge = "", + Message = "", + Sound = "", + Icon = "", + Image = "", + Language = "", + Silent = false, + Tag = "", + ClickAction = "", + VibratePattern = "", + Data = new Dictionary() + { + {"param1", "value1"} + } + }); + + ``` diff --git a/samples/AdsPushSample.Api/appsettings.Development.json b/samples/AdsPushSample.Api/appsettings.Development.json index 1c19a1e..cb766a9 100644 --- a/samples/AdsPushSample.Api/appsettings.Development.json +++ b/samples/AdsPushSample.Api/appsettings.Development.json @@ -9,14 +9,15 @@ "MyApp": { "TargetMappings": { "Ios": "Apns", - "Android": "FirebaseCloudMessaging" + "Android": "FirebaseCloudMessaging", + "BrowserAndPwa": "VapidWebPush" }, "Apns": { - "P8PrivateKey": "", - "P8PrivateKeyId": "<10 digit p8 certificate id. Usually a part of a downloadable certificate filename>", - "TeamId": "", - "AppBundleIdentifier": "", - "EnvironmentType": "" + "P8PrivateKey": "", + "P8PrivateKeyId": "<10-digit p8 certificate id; often part of a downloadable certificate filename>", + "TeamId": "<10-digit Apple team id shown on the Apple Developer Membership Page>", + "AppBundleIdentifier": "", + "EnvironmentType": "" }, "FirebaseCloudMessaging": { "Type":"", @@ -29,6 +30,11 @@ "AuthProviderX509CertUrl": "", "TokenUri": "", "ClientX509CertUrl": "" + }, + "Vapid": { + "PublicKey": "", + "PrivateKey": "", + "Subject": "" } } } diff --git a/samples/AdsPushSample.ConsoleApp/Program.cs b/samples/AdsPushSample.ConsoleApp/Program.cs index 0802730..6e1c32f 100644 --- a/samples/AdsPushSample.ConsoleApp/Program.cs +++ b/samples/AdsPushSample.ConsoleApp/Program.cs @@ -6,6 +6,8 @@ using AdsPush.Abstraction; using AdsPush.Abstraction.APNS; using AdsPush.Abstraction.Settings; +using AdsPush.Abstraction.Vapid; +using AdsPush.Vapid; using FirebaseAdmin.Messaging; var builder = new AdsPushSenderBuilder(); @@ -19,32 +21,55 @@ //put your configurations hare. }; + +var vapidSettings = new AdsPushVapidSettings() +{ + //put your configurations hare. +}; + var sender = builder - .ConfigureApns(apnsSettings, null) + .ConfigureVapid(vapidSettings) + .ConfigureApns(apnsSettings) .ConfigureFirebase(firebaseSettings, AdsPushTarget.Android) .BuildSender(); -var apnDeviceToken = "15f6fdd0f34a7e0f46301a817536f0fb1b2ab05b09b3fae02beba2854a1a2a16"; +var basicPayload = new AdsPushBasicSendPayload() +{ + Title = AdsPushText.CreateUsingString("test"), + Detail = AdsPushText.CreateUsingString("detail"), + Badge = 52, + Sound = "default", + Parameters = new Dictionary() + { + { + "pushParam1", "value1" + }, + { + "pushParam2", "value2" + }, + } +}; + +var apnDeviceToken = "15f6fdd0f34a7e0f46301a817536f0fb1b2ab05b09b3fae02beba2854a1a2a16"; +//var apnDeviceTokenVapid = "{"endpoint:"...", "keys": {"auth":"...","p256dh":"..."}}"; await sender.BasicSendAsync( AdsPushTarget.Ios, apnDeviceToken, - new() - { - Title = AdsPushText.CreateUsingString("test"), - Detail = AdsPushText.CreateUsingString("detail"), - Badge = 52, - Sound = "default", - Parameters = new Dictionary() - { - { - "pushParam1", "value1" - }, - { - "pushParam2", "value2" - }, - } - }); + basicPayload); + +//For VAPID WebPush +string + endpoint = "https://fcm.googleapis.com/fcm/send/cIo6QJ4MMtQ:APA91bEGHCpZdHaUS7otb5_xU1zNWe6TAqria9phFm7M_9ZIiEyr0vXj3gRHbeIJMYvp2-SAVbgNrVvl7uBvU_VTLpIA0CLBcmqXuuEktGr0U4LVLvwWBibO68spJk7D-lr8R9zPyAXE", + p256dh = "BIjydse4Rij892SJN10xx1qbxDM6GrYXSfg7TGu90CVM1WmlTYzn_79psRqseyWdER969LGLjZmnXIhHPaKTyGE", + auth = "TkLGLzFeUU3C9SJJN6dLAA"; + +var subscription = VapidSubscription.FromParameters(endpoint, p256dh, auth); +await sender.BasicSendAsync( + AdsPushTarget.BrowserAndPwa, + subscription.ToAdsPushToken(), + basicPayload); + //for whole platform options //sample for Apns @@ -101,4 +126,30 @@ Body = "", ImageUrl = "" } - }); \ No newline at end of file + }); + +//Sample for VAPID WebPush +var vapidResult = await sender + .GetVapidSender() + .SendAsync( + subscription, + new VapidRequest() + { + Title = "", + Badge = "", + Message = "", + Sound = "", + Icon = "", + Image = "", + Language = "", + Silent = false, + Tag = "", + ClickAction = "", + VibratePattern = "", + Data = new Dictionary() + { + { + "param1", "value1" + } + } + }); diff --git a/samples/AdsPushSample.VapidClient/icon-180.png b/samples/AdsPushSample.VapidClient/icon-180.png new file mode 100644 index 0000000..caf3cfa Binary files /dev/null and b/samples/AdsPushSample.VapidClient/icon-180.png differ diff --git a/samples/AdsPushSample.VapidClient/icon.png b/samples/AdsPushSample.VapidClient/icon.png new file mode 100644 index 0000000..757011f Binary files /dev/null and b/samples/AdsPushSample.VapidClient/icon.png differ diff --git a/samples/AdsPushSample.VapidClient/index.html b/samples/AdsPushSample.VapidClient/index.html new file mode 100644 index 0000000..b8a1a93 --- /dev/null +++ b/samples/AdsPushSample.VapidClient/index.html @@ -0,0 +1,96 @@ + + + + + + + + + + + AdsPush Vapid Client Test + + + +

+

AdsPush Sample App

+ +
+ +
+ + +
+
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+ + + diff --git a/samples/AdsPushSample.VapidClient/manifest.json b/samples/AdsPushSample.VapidClient/manifest.json new file mode 100644 index 0000000..637e9fe --- /dev/null +++ b/samples/AdsPushSample.VapidClient/manifest.json @@ -0,0 +1,18 @@ +{ + "name": "AdsPushVapidClient", + "short_name": "AdsPushVapidClient", + "start_url": ".", + "display": "standalone", + "background_color": "#ffffff", + "theme_color": "#000000", + "icons": [ + { + "src": "icon.png", + "sizes": "192x192", + "type": "image/png" + } + ], + "splash_pages": { + "normal": "splash.html" + } +} diff --git a/samples/AdsPushSample.VapidClient/sample-subscription.json b/samples/AdsPushSample.VapidClient/sample-subscription.json new file mode 100644 index 0000000..c345413 --- /dev/null +++ b/samples/AdsPushSample.VapidClient/sample-subscription.json @@ -0,0 +1,7 @@ +{ + "endpoint": "<...>", + "keys": { + "auth": "<...>", + "p256dh": "<...>" + } +} diff --git a/samples/AdsPushSample.VapidClient/service-worker.js b/samples/AdsPushSample.VapidClient/service-worker.js new file mode 100644 index 0000000..8b7a79e --- /dev/null +++ b/samples/AdsPushSample.VapidClient/service-worker.js @@ -0,0 +1,44 @@ +self.addEventListener("push", (event) => { + if (!(self.Notification && self.Notification.permission === "granted")) { + return; + } + + const data = event.data?.json() ?? {}; + const icon = "icon.png"; + + const options = { + lang: data.lang || "en-US", + title: data.title, + body: data.message, + tag: data.tag, + silent: data.silent, + image: data.image, + vibrate: [200, 100, 200], + actions: data.actions || [], + icon, + data: { + url: data.click_action + } + }; + + //do your operations + if (options.silent) + return; + event.waitUntil(self.registration.showNotification(data.title, options)); + +}); + +self.addEventListener('notificationclick', function (event) { + event.notification.close(); // Bildirimi kapat + if (clients.openWindow && event.notification.data.url) { + event.waitUntil(clients.openWindow(event.notification.data.url)); + } +}); + +self.addEventListener('install', function (event) { + self.skipWaiting(); +}); + +self.addEventListener('activate', function (event) { + event.waitUntil(clients.claim()); // Hemen etkinleşmesini sağla +}); diff --git a/samples/AdsPushSample.VapidClient/splash-image.jpg b/samples/AdsPushSample.VapidClient/splash-image.jpg new file mode 100644 index 0000000..ca7859f Binary files /dev/null and b/samples/AdsPushSample.VapidClient/splash-image.jpg differ diff --git a/samples/AdsPushSample.VapidClient/splash.css b/samples/AdsPushSample.VapidClient/splash.css new file mode 100644 index 0000000..a4cb39a --- /dev/null +++ b/samples/AdsPushSample.VapidClient/splash.css @@ -0,0 +1,19 @@ +body, html { + margin: 0; + padding: 0; +} + +.splash-screen { + position: fixed; + width: 100%; + height: 100%; + top: 0; + left: 0; + z-index: 9999; +} + +.splash-screen img { + width: 100%; + height: 100%; + object-fit: cover; +} diff --git a/samples/AdsPushSample.VapidClient/splash.html b/samples/AdsPushSample.VapidClient/splash.html new file mode 100644 index 0000000..8be05ad --- /dev/null +++ b/samples/AdsPushSample.VapidClient/splash.html @@ -0,0 +1,12 @@ + + + + Splash Screen + + + +
+ Splash Screen +
+ + diff --git a/src/AdsPush.Abstraction/AdsPush.Abstraction.csproj b/src/AdsPush.Abstraction/AdsPush.Abstraction.csproj index 689d07c..e86c066 100644 --- a/src/AdsPush.Abstraction/AdsPush.Abstraction.csproj +++ b/src/AdsPush.Abstraction/AdsPush.Abstraction.csproj @@ -25,4 +25,5 @@ + diff --git a/src/AdsPush.Abstraction/AdsPushBasicSendPayload.cs b/src/AdsPush.Abstraction/AdsPushBasicSendPayload.cs index 4cc1017..2441c35 100644 --- a/src/AdsPush.Abstraction/AdsPushBasicSendPayload.cs +++ b/src/AdsPush.Abstraction/AdsPushBasicSendPayload.cs @@ -9,7 +9,7 @@ namespace AdsPush.Abstraction public class AdsPushBasicSendPayload { /// - /// + /// /// public AdsPushBasicSendPayload() { @@ -20,7 +20,7 @@ public AdsPushBasicSendPayload() /// Unique notification id. /// public string Id { get; set; } = Guid.NewGuid().ToString(); - + /// /// Basic notification tyoe. /// @@ -43,10 +43,10 @@ public AdsPushBasicSendPayload() /// Sound file name for notification. /// public string Sound { get; set; } - + /// /// Related group id to be able to group notification. - /// Ios thread id + /// Ios thread id /// public string GroupId { get; set; } @@ -60,6 +60,11 @@ public AdsPushBasicSendPayload() /// /// public Dictionary Parameters { get; set; } - + + /// + /// Time to leve for notification. + /// Pass null for platform default. + /// + public TimeSpan? Ttl { get; set; } } } diff --git a/src/AdsPush.Abstraction/AdsPushProvider.cs b/src/AdsPush.Abstraction/AdsPushProvider.cs index 5cf5378..e0369eb 100644 --- a/src/AdsPush.Abstraction/AdsPushProvider.cs +++ b/src/AdsPush.Abstraction/AdsPushProvider.cs @@ -12,6 +12,11 @@ public enum AdsPushProvider /// /// FCM - Firebase Cloud Messaging. /// - Firebase + Firebase, + + /// + /// Web Push + /// + VapidWebPush, } } diff --git a/src/AdsPush.Abstraction/AdsPushTarget.cs b/src/AdsPush.Abstraction/AdsPushTarget.cs index 2da8354..9a756aa 100644 --- a/src/AdsPush.Abstraction/AdsPushTarget.cs +++ b/src/AdsPush.Abstraction/AdsPushTarget.cs @@ -13,5 +13,9 @@ public enum AdsPushTarget /// Android /// Android, + /// + /// Mobile & PC Browsers, progressive web application (PWA) + /// + BrowserAndPwa } } diff --git a/src/AdsPush.Abstraction/Settings/AdsPushAppSettings.cs b/src/AdsPush.Abstraction/Settings/AdsPushAppSettings.cs index d8f75b9..1a6a571 100644 --- a/src/AdsPush.Abstraction/Settings/AdsPushAppSettings.cs +++ b/src/AdsPush.Abstraction/Settings/AdsPushAppSettings.cs @@ -8,26 +8,31 @@ namespace AdsPush.Abstraction.Settings public class AdsPushAppSettings { /// - /// + /// /// public AdsPushAppSettings() { this.TargetMappings = new Dictionary(); } - + /// /// Mapping for platform and target service for that platform. /// public Dictionary TargetMappings { get; set; } - + /// /// Firebase configuration. /// public AdsPushFirebaseSettings Firebase { get; set; } - + /// /// APNS Configuration. /// public AdsPushAPNSSettings Apns { get; set; } + + /// + /// Vapid configuration + /// + public AdsPushVapidSettings Vapid { get; set; } } } diff --git a/src/AdsPush.Abstraction/Settings/AdsPushVapidSettings.cs b/src/AdsPush.Abstraction/Settings/AdsPushVapidSettings.cs new file mode 100644 index 0000000..a0edb8c --- /dev/null +++ b/src/AdsPush.Abstraction/Settings/AdsPushVapidSettings.cs @@ -0,0 +1,20 @@ +namespace AdsPush.Abstraction.Settings +{ + public class AdsPushVapidSettings + { + /// + /// Gets or sets the public key for VAPID authentication. This should be a URL-safe base64 encoded string. + /// + public string PublicKey { get; set; } + + /// + /// Gets or sets the private key for VAPID authentication. This should be a URL-safe base64 encoded string. + /// + public string PrivateKey { get; set; } + + /// + /// Gets or sets the subject for VAPID authentication. This should be a mailto or a URL. + /// + public string Subject { get; set; } + } +} diff --git a/src/AdsPush.Abstraction/Vapid/VapidError.cs b/src/AdsPush.Abstraction/Vapid/VapidError.cs new file mode 100644 index 0000000..6ca79eb --- /dev/null +++ b/src/AdsPush.Abstraction/Vapid/VapidError.cs @@ -0,0 +1,33 @@ +using System.Net.Http; + +namespace AdsPush.Abstraction.Vapid +{ + /// + /// Vapid error. + /// + public class VapidError + { + public VapidError() + { + } + + public VapidError( + VapidErrorReasonCode reasonCode, + HttpResponseMessage httpResponse) + { + this.ReasonCode = reasonCode; + this.HttpResponse = httpResponse; + } + + /// + /// Vapid error reason. + /// + public VapidErrorReasonCode ReasonCode { get; set; } + + /// + /// APNS Response. + /// + /// + public HttpResponseMessage HttpResponse { get; set; } + } +} diff --git a/src/AdsPush.Abstraction/Vapid/VapidErrorReasonCode.cs b/src/AdsPush.Abstraction/Vapid/VapidErrorReasonCode.cs new file mode 100644 index 0000000..1fec8f2 --- /dev/null +++ b/src/AdsPush.Abstraction/Vapid/VapidErrorReasonCode.cs @@ -0,0 +1,30 @@ +namespace AdsPush.Abstraction.Vapid +{ + public enum VapidErrorReasonCode + { + /// + /// Unknown error, possibly due to an unexpected scenario. + /// + UnknownError = 0, + + /// + /// Push token is invalid or expired. + /// + InvalidToken = 1, + + /// + /// The push notification service is unavailable or unreachable. + /// + ServiceUnavailable = 2, + + /// + /// One or more of the provided arguments is invalid or missing. + /// + InvalidArgument = 3, + + /// + /// The authentication configuration is missing or incorrect. + /// + InvalidAuthConfiguration = 4, + } +} diff --git a/src/AdsPush.Abstraction/Vapid/VapidRequest.cs b/src/AdsPush.Abstraction/Vapid/VapidRequest.cs new file mode 100644 index 0000000..92682b3 --- /dev/null +++ b/src/AdsPush.Abstraction/Vapid/VapidRequest.cs @@ -0,0 +1,87 @@ +using System.Collections.Generic; + +namespace AdsPush.Abstraction.Vapid +{ + public class VapidRequest + { + /// + /// The title of the notification. + /// + public string Title { get; set; } + + /// + /// The message body of the notification. + /// + public string Message { get; set; } + + /// + /// URL of the image to be displayed in the notification. + /// + public string Image { get; set; } + + /// + /// A string that categorizes a notification. + /// + public string Tag { get; set; } + + /// + /// The URL of the badge to be displayed in the notification. + /// + public string Badge { get; set; } + + /// + /// The URL of the icon to be displayed in the notification. + /// + public string Icon { get; set; } + + /// + /// The sound to play when the notification is displayed. + /// + public string Sound { get; set; } + + /// + /// URL to be opened when the notification is clicked. + /// + public string ClickAction { get; set; } + + /// + /// Time to live for the notification, in seconds. Defines how long a push message is retained if the user's device is offline. If not delivered in this time, the message will be dropped. + /// + public long? TTL { get; set; } + + /// + /// Indicates whether the notification requires user interaction. + /// + public bool RequireInteraction { get; set; } + + /// + /// Set the notification background or not. + /// + public bool Silent { get; set; } + + /// + /// Pattern for the vibration (if supported). + /// + public string VibratePattern { get; set; } + + /// + /// List of actions to be displayed in the notification. + /// + public List Actions { get; set; } + + /// + /// Custom data payload for the notification. + /// + public Dictionary Data { get; set; } + + /// + /// List of URL arguments for Safari. + /// + public List UrlArgs { get; set; } + + /// + /// Language code for the notification. + /// + public string Language { get; set; } + } +} diff --git a/src/AdsPush.Abstraction/Vapid/VapidRequestActionAction.cs b/src/AdsPush.Abstraction/Vapid/VapidRequestActionAction.cs new file mode 100644 index 0000000..87d4ab9 --- /dev/null +++ b/src/AdsPush.Abstraction/Vapid/VapidRequestActionAction.cs @@ -0,0 +1,20 @@ +namespace AdsPush.Abstraction.Vapid +{ + public class VapidRequestActionAction + { + /// + /// Identifier for the action. + /// + public string Action { get; set; } + + /// + /// The title for the action button. + /// + public string Title { get; set; } + + /// + /// URL of the icon to be displayed for the action button. + /// + public string Icon { get; set; } + } +} diff --git a/src/AdsPush.Abstraction/Vapid/VapidResponse.cs b/src/AdsPush.Abstraction/Vapid/VapidResponse.cs new file mode 100644 index 0000000..73fe8b6 --- /dev/null +++ b/src/AdsPush.Abstraction/Vapid/VapidResponse.cs @@ -0,0 +1,32 @@ +using AdsPush.Abstraction.APNS; + +namespace AdsPush.Abstraction.Vapid +{ + /// + /// Generic service response + /// + public class VapidResponse + { + public VapidResponse() + { + } + + public VapidResponse( + bool isSuccess, + VapidError error) + { + this.IsSuccess = isSuccess; + this.Error = error; + } + + /// + /// The service response success or not. + /// + public bool IsSuccess { get; set; } + + /// + /// Represents error if occurrences. + /// + public VapidError Error { get; set; } + } +} diff --git a/src/AdsPush.Firebase/AdsPush.Firebase.csproj b/src/AdsPush.Firebase/AdsPush.Firebase.csproj index 8eae929..d5f3c2b 100644 --- a/src/AdsPush.Firebase/AdsPush.Firebase.csproj +++ b/src/AdsPush.Firebase/AdsPush.Firebase.csproj @@ -4,9 +4,9 @@ netstandard2.0 AdsPush.Firebase Anil Dursun SENEL - push;APNS;service-side-push-library;Firebase;Apple;FCM;push notification + push;APNS;service-side-push-library;Firebase;Apple;FCM;VAPID;WebPush;push notification - AdsPush is the server-side push notification library that fully supports APNS(Apple Push Notification Service) and FCM (Firebase Cloud Messaging) features and works with the the most common .NET platorms. It puts togetter good abtraction, easy using and full support for advanced use cases. + AdsPush is the server-side push notification library that fully supports APNS(Apple Push Notification Service), FCM (Firebase Cloud Messaging) and VAPID WebPush features and works with the the most common .NET platorms. It puts togetter good abtraction, easy using and full support for advanced use cases. logo.png https://github.com/adessoTurkey-dotNET/AdsPush diff --git a/src/AdsPush.Firebase/Extensions/MappingExtension.cs b/src/AdsPush.Firebase/Extensions/MappingExtension.cs index aad4274..ece4cf3 100644 --- a/src/AdsPush.Firebase/Extensions/MappingExtension.cs +++ b/src/AdsPush.Firebase/Extensions/MappingExtension.cs @@ -5,6 +5,7 @@ using FirebaseAdmin.Auth; using FirebaseAdmin.Messaging; using AdsPush.Abstraction; +using AdsPush.Abstraction.APNS; namespace AdsPush.Firebase.Extensions { @@ -48,15 +49,21 @@ public static class MappingExtension LocKey = payload.Detail.LocalizationKey, LocArgs = payload.Detail.LocalizationArgs, } - }, - Headers = new Dictionary() + } + }; + var headers = new Dictionary() + { { - { - "apns-push-type", payload.PushType.ToString() - } - }, + "apns-push-type", payload.PushType.ToString() + } }; + if (payload.Ttl.HasValue) + { + headers.Add("apns-expiration", APNSExpiration.FromTimeSpan(payload.Ttl.Value).ApnsExpirationValue.ToString()); + } + + message.Apns.Headers = headers; break; case AdsPushTarget.Android: message.Android = new AndroidConfig() @@ -75,6 +82,7 @@ public static class MappingExtension Sound = payload.Sound, }, Data = payload.Parameters.ToDictionary(x => x.Key, x => x.Value.ToString()), + TimeToLive = payload.Ttl }; break; @@ -140,4 +148,4 @@ public static class MappingExtension } } } -} \ No newline at end of file +} diff --git a/src/AdsPush.Vapid/AdsPush.Vapid.csproj b/src/AdsPush.Vapid/AdsPush.Vapid.csproj new file mode 100644 index 0000000..051d7cd --- /dev/null +++ b/src/AdsPush.Vapid/AdsPush.Vapid.csproj @@ -0,0 +1,50 @@ + + + + netstandard2.0 + AdsPush.Vapid + Anil Dursun SENEL + push;APNS;server-side-push-library;Firebase;Apple;FCM;VAPID;WebPush;push notification + + AdsPush is the server-side push notification library that fully supports APNS(Apple Push Notification Service), FCM (Firebase Cloud Messaging) and VAPID WebPush features and works with the the most common .NET platorms. It puts togetter good abtraction, easy using and full support for advanced use cases. + + logo.png + https://github.com/adessoTurkey-dotNET/AdsPush + https://github.com/adessoTurkey-dotNET/AdsPush + LICENSE + Copyright (c) 2023, Anıl Dursun ŞENEL + README-NUGET.md + true + + + + + + + + + true + / + LICENSE + + + true + / + logo.png + + + true + / + README-NUGET.md + + + + + + + + + + + + diff --git a/src/AdsPush.Vapid/Extensions/BuilderExtensions.cs b/src/AdsPush.Vapid/Extensions/BuilderExtensions.cs new file mode 100644 index 0000000..8354259 --- /dev/null +++ b/src/AdsPush.Vapid/Extensions/BuilderExtensions.cs @@ -0,0 +1,38 @@ +using System; +using System.Net.Http; +using AdsPush.Vapid.Settings; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace AdsPush.Vapid.Extensions +{ + public static class BuilderExtensions + { + /// + /// Configures to be able to creates instance. + /// + /// + /// + /// + /// + public static IServiceCollection AddVapidNotificationServiceFactory( + this IServiceCollection services, + Action vapidSettingsSectionsAction = null, + HttpClient httpClient = null) + { + var vapidSettingsSection = new VapidSettingsSection(); + vapidSettingsSectionsAction?.Invoke(vapidSettingsSection); + + services.AddSingleton(serviceProvider => + { + vapidSettingsSection = vapidSettingsSectionsAction is null + ? serviceProvider.GetService>()?.Value + : vapidSettingsSection; + + return new VapidPushNotificationSenderFactory(vapidSettingsSection, httpClient ?? new HttpClient()); + }); + + return services; + } + } +} diff --git a/src/AdsPush.Vapid/Extensions/MappingExtensions.cs b/src/AdsPush.Vapid/Extensions/MappingExtensions.cs new file mode 100644 index 0000000..5be185c --- /dev/null +++ b/src/AdsPush.Vapid/Extensions/MappingExtensions.cs @@ -0,0 +1,62 @@ +using System.Linq; +using AdsPush.Abstraction; +using AdsPush.Abstraction.Vapid; + +namespace AdsPush.Vapid.Extensions +{ + public static class MappingExtensions + { + public static AdsPushException CreateException( + this VapidError error) + { + switch (error.ReasonCode) + { + case VapidErrorReasonCode.UnknownError: + return new AdsPushException( + error.ReasonCode.ToString(), + AdsPushErrorType.Unknown, + error.HttpResponse); + case VapidErrorReasonCode.InvalidToken: + return new AdsPushException( + error.ReasonCode.ToString(), + AdsPushErrorType.InvalidToken, + error.HttpResponse); + case VapidErrorReasonCode.ServiceUnavailable: + return new AdsPushException( + error.ReasonCode.ToString(), + AdsPushErrorType.ServiceUnavailable, + error.HttpResponse); + case VapidErrorReasonCode.InvalidArgument: + return new AdsPushException( + error.ReasonCode.ToString(), + AdsPushErrorType.InvalidArgument, + error.HttpResponse); + case VapidErrorReasonCode.InvalidAuthConfiguration: + return new AdsPushException( + error.ReasonCode.ToString(), + AdsPushErrorType.InvalidAuthConfiguration, + error.HttpResponse); + default: + return new AdsPushException( + error.ReasonCode.ToString(), + AdsPushErrorType.Unknown, + error.HttpResponse); + } + } + + public static VapidRequest CreateRequest( + this AdsPushBasicSendPayload payload) + { + return new VapidRequest() + { + Title = payload.Title.Text, + Message = payload.Detail.Text, + Tag = payload.GroupId, + Sound = payload.Sound, + Data = payload.Parameters.ToDictionary(x => x.Key, x => x.Value.ToString()), + Silent = payload.PushType is AdsPushType.Background, + TTL = (long?)payload.Ttl?.TotalSeconds + }; + } + } +} diff --git a/src/AdsPush.Vapid/IVapidPushNotificationSender.cs b/src/AdsPush.Vapid/IVapidPushNotificationSender.cs new file mode 100644 index 0000000..0fdfa8a --- /dev/null +++ b/src/AdsPush.Vapid/IVapidPushNotificationSender.cs @@ -0,0 +1,49 @@ +using System.Threading; +using System.Threading.Tasks; +using AdsPush.Abstraction; +using AdsPush.Abstraction.Vapid; + +namespace AdsPush.Vapid +{ + /// + /// Defines operations for sending VAPID notifications. + /// + public interface IVapidPushNotificationSender + { + /// + /// Sends a VAPID push notification with the provided subscription and payload. + /// + /// The subscription information. + /// The payload as a string. + /// The cancellation token. + /// A task representing the asynchronous operation. + Task SendAsync( + VapidSubscription subscription, + string payload, + CancellationToken cancellationToken = default); + + /// + /// Sends a VAPID push notification with the provided subscription and payload. + /// + /// The subscription information. + /// The payload as a model. + /// The cancellation token. + /// A task representing the asynchronous operation. + Task SendAsync( + VapidSubscription subscription, + VapidRequest payload, + CancellationToken cancellationToken = default); + + /// + /// Sends a VAPID push notification with the provided subscription JSON and payload. + /// + /// The subscription information as JSON. + /// The payload as an model. + /// The cancellation token. + /// A task representing the asynchronous operation. + Task SendAsync( + string subscriptionJson, + AdsPushBasicSendPayload payload, + CancellationToken cancellationToken = default); + } +} diff --git a/src/AdsPush.Vapid/IVapidPushNotificationSenderFactory.cs b/src/AdsPush.Vapid/IVapidPushNotificationSenderFactory.cs new file mode 100644 index 0000000..f2d9a86 --- /dev/null +++ b/src/AdsPush.Vapid/IVapidPushNotificationSenderFactory.cs @@ -0,0 +1,14 @@ +using AdsPush.Abstraction.Settings; + +namespace AdsPush.Vapid +{ + public interface IVapidPushNotificationSenderFactory + { + IVapidPushNotificationSender GetSender( + string appName); + + IVapidPushNotificationSender GetSender( + string appName, + AdsPushVapidSettings vapidSettings); + } +} diff --git a/src/AdsPush.Vapid/Settings/VapidSettingsSection.cs b/src/AdsPush.Vapid/Settings/VapidSettingsSection.cs new file mode 100644 index 0000000..76f6a46 --- /dev/null +++ b/src/AdsPush.Vapid/Settings/VapidSettingsSection.cs @@ -0,0 +1,9 @@ +using System.Collections.Generic; +using AdsPush.Abstraction.Settings; + +namespace AdsPush.Vapid.Settings +{ + public class VapidSettingsSection : Dictionary + { + } +} diff --git a/src/AdsPush.Vapid/Util/ECKeyHelper.cs b/src/AdsPush.Vapid/Util/ECKeyHelper.cs new file mode 100644 index 0000000..92f2f6c --- /dev/null +++ b/src/AdsPush.Vapid/Util/ECKeyHelper.cs @@ -0,0 +1,66 @@ +using System; +using System.IO; +using Org.BouncyCastle.Asn1; +using Org.BouncyCastle.Asn1.Nist; +using Org.BouncyCastle.Crypto; +using Org.BouncyCastle.Crypto.Parameters; +using Org.BouncyCastle.OpenSsl; +using Org.BouncyCastle.Security; + +namespace AdsPush.Vapid.Util +{ + internal static class ECKeyHelper + { + public static ECPrivateKeyParameters GetPrivateKey( + byte[] privateKey) + { + Asn1Object version = new DerInteger(1); + Asn1Object derEncodedKey = new DerOctetString(privateKey); + Asn1Object keyTypeParameters = new DerTaggedObject(0, new DerObjectIdentifier(@"1.2.840.10045.3.1.7")); + + Asn1Object derSequence = new DerSequence(version, derEncodedKey, keyTypeParameters); + + var base64EncodedDerSequence = Convert.ToBase64String(derSequence.GetDerEncoded()); + var pemKey = "-----BEGIN EC PRIVATE KEY-----\n"; + pemKey += base64EncodedDerSequence; + pemKey += "\n-----END EC PRIVATE KEY----"; + + var reader = new StringReader(pemKey); + var pemReader = new PemReader(reader); + var keyPair = (AsymmetricCipherKeyPair)pemReader.ReadObject(); + + return (ECPrivateKeyParameters)keyPair.Private; + } + + public static ECPublicKeyParameters GetPublicKey( + byte[] publicKey) + { + Asn1Object keyTypeParameters = new DerSequence(new DerObjectIdentifier(@"1.2.840.10045.2.1"), + new DerObjectIdentifier(@"1.2.840.10045.3.1.7")); + Asn1Object derEncodedKey = new DerBitString(publicKey); + + Asn1Object derSequence = new DerSequence(keyTypeParameters, derEncodedKey); + + var base64EncodedDerSequence = Convert.ToBase64String(derSequence.GetDerEncoded()); + var pemKey = "-----BEGIN PUBLIC KEY-----\n"; + pemKey += base64EncodedDerSequence; + pemKey += "\n-----END PUBLIC KEY-----"; + + var reader = new StringReader(pemKey); + var pemReader = new PemReader(reader); + var keyPair = pemReader.ReadObject(); + return (ECPublicKeyParameters)keyPair; + } + + public static AsymmetricCipherKeyPair GenerateKeys() + { + var ecParameters = NistNamedCurves.GetByName("P-256"); + var ecSpec = new ECDomainParameters(ecParameters.Curve, ecParameters.G, ecParameters.N, ecParameters.H, + ecParameters.GetSeed()); + var keyPairGenerator = GeneratorUtilities.GetKeyPairGenerator("ECDH"); + keyPairGenerator.Init(new ECKeyGenerationParameters(ecSpec, new SecureRandom())); + + return keyPairGenerator.GenerateKeyPair(); + } + } +} diff --git a/src/AdsPush.Vapid/Util/EncryptionResult.cs b/src/AdsPush.Vapid/Util/EncryptionResult.cs new file mode 100644 index 0000000..4cecc01 --- /dev/null +++ b/src/AdsPush.Vapid/Util/EncryptionResult.cs @@ -0,0 +1,21 @@ +namespace AdsPush.Vapid.Util +{ + // @LogicSoftware + // Originally From: https://github.com/LogicSoftware/WebPushEncryption/blob/master/src/EncryptionResult.cs + internal class EncryptionResult + { + public byte[] PublicKey { get; set; } + public byte[] Payload { get; set; } + public byte[] Salt { get; set; } + + public string Base64EncodePublicKey() + { + return UrlBase64.Encode(this.PublicKey); + } + + public string Base64EncodeSalt() + { + return UrlBase64.Encode(this.Salt); + } + } +} diff --git a/src/AdsPush.Vapid/Util/Encryptor.cs b/src/AdsPush.Vapid/Util/Encryptor.cs new file mode 100644 index 0000000..ab11cdb --- /dev/null +++ b/src/AdsPush.Vapid/Util/Encryptor.cs @@ -0,0 +1,177 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using Org.BouncyCastle.Crypto.Digests; +using Org.BouncyCastle.Crypto.Engines; +using Org.BouncyCastle.Crypto.Macs; +using Org.BouncyCastle.Crypto.Modes; +using Org.BouncyCastle.Crypto.Parameters; +using Org.BouncyCastle.Security; + +namespace AdsPush.Vapid.Util +{ + // @LogicSoftware + // Originally from https://github.com/LogicSoftware/WebPushEncryption/blob/master/src/Encryptor.cs + internal static class Encryptor + { + public static EncryptionResult Encrypt( + string userKey, + string userSecret, + string payload) + { + var userKeyBytes = UrlBase64.Decode(userKey); + var userSecretBytes = UrlBase64.Decode(userSecret); + var payloadBytes = Encoding.UTF8.GetBytes(payload); + + return Encrypt(userKeyBytes, userSecretBytes, payloadBytes); + } + + private static EncryptionResult Encrypt( + byte[] userKey, + byte[] userSecret, + byte[] payload) + { + var salt = GenerateSalt(16); + var serverKeyPair = ECKeyHelper.GenerateKeys(); + + var ecdhAgreement = AgreementUtilities.GetBasicAgreement("ECDH"); + ecdhAgreement.Init(serverKeyPair.Private); + + var userPublicKey = ECKeyHelper.GetPublicKey(userKey); + + var key = ecdhAgreement.CalculateAgreement(userPublicKey).ToByteArrayUnsigned(); + var serverPublicKey = ((ECPublicKeyParameters)serverKeyPair.Public).Q.GetEncoded(false); + + var prk = HKDF(userSecret, key, Encoding.UTF8.GetBytes("Content-Encoding: auth\0"), 32); + var cek = HKDF(salt, prk, CreateInfoChunk("aesgcm", userKey, serverPublicKey), 16); + var nonce = HKDF(salt, prk, CreateInfoChunk("nonce", userKey, serverPublicKey), 12); + + var input = AddPaddingToInput(payload); + var encryptedMessage = EncryptAes(nonce, cek, input); + + return new EncryptionResult + { + Salt = salt, + Payload = encryptedMessage, + PublicKey = serverPublicKey + }; + } + + private static byte[] GenerateSalt( + int length) + { + var salt = new byte[length]; + var random = new Random(); + random.NextBytes(salt); + return salt; + } + + private static byte[] AddPaddingToInput( + byte[] data) + { + var input = new byte[0 + 2 + data.Length]; + Buffer.BlockCopy(ConvertInt(0), 0, input, 0, 2); + Buffer.BlockCopy(data, 0, input, 0 + 2, data.Length); + return input; + } + + private static byte[] EncryptAes( + byte[] nonce, + byte[] cek, + byte[] message) + { + var cipher = new GcmBlockCipher(new AesEngine()); + var parameters = new AeadParameters(new KeyParameter(cek), 128, nonce); + cipher.Init(true, parameters); + + //Generate Cipher Text With Auth Tag + var cipherText = new byte[cipher.GetOutputSize(message.Length)]; + var len = cipher.ProcessBytes(message, 0, message.Length, cipherText, 0); + cipher.DoFinal(cipherText, len); + + //byte[] tag = cipher.GetMac(); + return cipherText; + } + + private static byte[] HKDFSecondStep( + byte[] key, + byte[] info, + int length) + { + var hmac = new HmacSha256(key); + var infoAndOne = info.Concat(new byte[] + { + 0x01 + }).ToArray(); + var result = hmac.ComputeHash(infoAndOne); + + if (result.Length > length) + { + Array.Resize(ref result, length); + } + + return result; + } + + public static byte[] HKDF( + byte[] salt, + byte[] prk, + byte[] info, + int length) + { + var hmac = new HmacSha256(salt); + var key = hmac.ComputeHash(prk); + + return HKDFSecondStep(key, info, length); + } + + public static byte[] ConvertInt( + int number) + { + var output = BitConverter.GetBytes(Convert.ToUInt16(number)); + if (BitConverter.IsLittleEndian) + { + Array.Reverse(output); + } + + return output; + } + + public static byte[] CreateInfoChunk( + string type, + byte[] recipientPublicKey, + byte[] senderPublicKey) + { + var output = new List(); + output.AddRange(Encoding.UTF8.GetBytes($"Content-Encoding: {type}\0P-256\0")); + output.AddRange(ConvertInt(recipientPublicKey.Length)); + output.AddRange(recipientPublicKey); + output.AddRange(ConvertInt(senderPublicKey.Length)); + output.AddRange(senderPublicKey); + return output.ToArray(); + } + } + + public class HmacSha256 + { + private readonly HMac _hmac; + + public HmacSha256( + byte[] key) + { + this._hmac = new HMac(new Sha256Digest()); + this._hmac.Init(new KeyParameter(key)); + } + + public byte[] ComputeHash( + byte[] value) + { + var resBuf = new byte[this._hmac.GetMacSize()]; + this._hmac.BlockUpdate(value, 0, value.Length); + this._hmac.DoFinal(resBuf, 0); + + return resBuf; + } + } +} diff --git a/src/AdsPush.Vapid/Util/JwsSigner.cs b/src/AdsPush.Vapid/Util/JwsSigner.cs new file mode 100644 index 0000000..bb93305 --- /dev/null +++ b/src/AdsPush.Vapid/Util/JwsSigner.cs @@ -0,0 +1,87 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using Newtonsoft.Json; +using Org.BouncyCastle.Crypto.Digests; +using Org.BouncyCastle.Crypto.Parameters; +using Org.BouncyCastle.Crypto.Signers; + +namespace AdsPush.Vapid.Util +{ + internal class JwsSigner + { + private readonly ECPrivateKeyParameters _privateKey; + + public JwsSigner( + ECPrivateKeyParameters privateKey) + { + this._privateKey = privateKey; + } + + /// + /// Generates a Jws Signature. + /// + /// + /// + /// + public string GenerateSignature( + Dictionary header, + Dictionary payload) + { + var securedInput = SecureInput(header, payload); + var message = Encoding.UTF8.GetBytes(securedInput); + + var hashedMessage = Sha256Hash(message); + + var signer = new ECDsaSigner(); + signer.Init(true, this._privateKey); + var results = signer.GenerateSignature(hashedMessage); + + // Concated to create signature + var a = results[0].ToByteArrayUnsigned(); + var b = results[1].ToByteArrayUnsigned(); + + // a,b are required to be exactly the same length of bytes + if (a.Length != b.Length) + { + var largestLength = Math.Max(a.Length, b.Length); + a = ByteArrayPadLeft(a, largestLength); + b = ByteArrayPadLeft(b, largestLength); + } + + var signature = UrlBase64.Encode(a.Concat(b).ToArray()); + return $"{securedInput}.{signature}"; + } + + private static string SecureInput( + Dictionary header, + Dictionary payload) + { + var encodeHeader = UrlBase64.Encode(Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(header))); + var encodePayload = UrlBase64.Encode(Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(payload))); + + return $"{encodeHeader}.{encodePayload}"; + } + + private static byte[] ByteArrayPadLeft( + byte[] src, + int size) + { + var dst = new byte[size]; + var startAt = dst.Length - src.Length; + Array.Copy(src, 0, dst, startAt, src.Length); + return dst; + } + + private static byte[] Sha256Hash( + byte[] message) + { + var sha256Digest = new Sha256Digest(); + sha256Digest.BlockUpdate(message, 0, message.Length); + var hash = new byte[sha256Digest.GetDigestSize()]; + sha256Digest.DoFinal(hash, 0); + return hash; + } + } +} diff --git a/src/AdsPush.Vapid/Util/UrlBase64.cs b/src/AdsPush.Vapid/Util/UrlBase64.cs new file mode 100644 index 0000000..0872217 --- /dev/null +++ b/src/AdsPush.Vapid/Util/UrlBase64.cs @@ -0,0 +1,36 @@ +using System; + +namespace AdsPush.Vapid.Util +{ + internal static class UrlBase64 + { + /// + /// Decodes a url-safe base64 string into bytes + /// + /// + /// + public static byte[] Decode( + string base64) + { + base64 = base64.Replace('-', '+').Replace('_', '/'); + + while (base64.Length % 4 != 0) + { + base64 += "="; + } + + return Convert.FromBase64String(base64); + } + + /// + /// Encodes bytes into url-safe base64 string + /// + /// + /// + public static string Encode( + byte[] data) + { + return Convert.ToBase64String(data).Replace('+', '-').Replace('/', '_').TrimEnd('='); + } + } +} diff --git a/src/AdsPush.Vapid/VapidHelper.cs b/src/AdsPush.Vapid/VapidHelper.cs new file mode 100644 index 0000000..fbcdd10 --- /dev/null +++ b/src/AdsPush.Vapid/VapidHelper.cs @@ -0,0 +1,195 @@ +using System; +using System.Collections.Generic; +using AdsPush.Vapid.Util; +using Org.BouncyCastle.Crypto.Parameters; + +namespace AdsPush.Vapid +{ + public static class VapidHelper + { + /// + /// Generate vapid keys + /// + public static VapidKeyGenerationResult GenerateVapidKeys() + { + var keys = ECKeyHelper.GenerateKeys(); + var publicKey = ((ECPublicKeyParameters)keys.Public).Q.GetEncoded(false); + var privateKey = ((ECPrivateKeyParameters)keys.Private).D.ToByteArrayUnsigned(); + + return new VapidKeyGenerationResult( + UrlBase64.Encode(publicKey), + UrlBase64.Encode(ByteArrayPadLeft(privateKey, 32))); + } + + /// + /// This method takes the required VAPID parameters and returns the required + /// header to be added to a Web Push Protocol Request. + /// + /// This must be the origin of the push service. + /// This should be a URL or a 'mailto:' email address + /// The VAPID public key as a base64 encoded string + /// The VAPID private key as a base64 encoded string + /// The expiration of the VAPID JWT. + /// A dictionary of header key/value pairs. + public static Dictionary GetVapidHeaders( + string audience, + string subject, + string publicKey, + string privateKey, + long expiration = -1) + { + ValidateAudience(audience); + ValidateSubject(subject); + ValidatePublicKey(publicKey); + ValidatePrivateKey(privateKey); + + var decodedPrivateKey = UrlBase64.Decode(privateKey); + if (expiration == -1) + { + expiration = UnixTimeNow() + 43200; + } + else + { + ValidateExpiration(expiration); + } + + var header = new Dictionary + { + { + "typ", "JWT" + }, + { + "alg", "ES256" + } + }; + + var jwtPayload = new Dictionary + { + { + "aud", audience + }, + { + "exp", expiration + }, + { + "sub", subject + } + }; + + var signingKey = ECKeyHelper.GetPrivateKey(decodedPrivateKey); + var signer = new JwsSigner(signingKey); + var token = signer.GenerateSignature(header, jwtPayload); + + var results = new Dictionary + { + { + "Authorization", "WebPush " + token + }, + { + "Crypto-Key", "p256ecdsa=" + publicKey + } + }; + + return results; + } + + private static void ValidateAudience( + string audience) + { + if (string.IsNullOrEmpty(audience)) + { + throw new ArgumentException(@"No audience could be generated for VAPID."); + } + + if (audience.Length == 0) + { + throw new ArgumentException( + @"The audience value must be a string containing the origin of a push service. " + audience); + } + + if (!Uri.IsWellFormedUriString(audience, UriKind.Absolute)) + { + throw new ArgumentException(@"VAPID audience is not a url."); + } + } + + private static void ValidateSubject( + string subject) + { + if (string.IsNullOrEmpty(subject)) + { + throw new ArgumentException(@"A subject is required"); + } + + if (subject.Length == 0) + { + throw new ArgumentException(@"The subject value must be a string containing a url or mailto: address."); + } + + if (!subject.StartsWith("mailto:")) + { + if (!Uri.IsWellFormedUriString(subject, UriKind.Absolute)) + { + throw new ArgumentException(@"Subject is not a valid URL or mailto address"); + } + } + } + + private static void ValidatePublicKey( + string publicKey) + { + if (string.IsNullOrEmpty(publicKey)) + { + throw new ArgumentException(@"Valid public key not set"); + } + + var decodedPublicKey = UrlBase64.Decode(publicKey); + + if (decodedPublicKey.Length != 65) + { + throw new ArgumentException(@"Vapid public key must be 65 characters long when decoded"); + } + } + + private static void ValidatePrivateKey( + string privateKey) + { + if (string.IsNullOrEmpty(privateKey)) + { + throw new ArgumentException(@"Valid private key not set"); + } + + var decodedPrivateKey = UrlBase64.Decode(privateKey); + + if (decodedPrivateKey.Length != 32) + { + throw new ArgumentException(@"Vapid private key should be 32 bytes long when decoded."); + } + } + + private static void ValidateExpiration( + long expiration) + { + if (expiration <= UnixTimeNow()) + { + throw new ArgumentException(@"Vapid expiration must be a unix timestamp in the future"); + } + } + + private static long UnixTimeNow() + { + var timeSpan = DateTime.UtcNow - new DateTime(1970, 1, 1, 0, 0, 0); + return (long)timeSpan.TotalSeconds; + } + + private static byte[] ByteArrayPadLeft( + byte[] src, + int size) + { + var dst = new byte[size]; + var startAt = dst.Length - src.Length; + Array.Copy(src, 0, dst, startAt, src.Length); + return dst; + } + } +} diff --git a/src/AdsPush.Vapid/VapidKeyGenerationResult.cs b/src/AdsPush.Vapid/VapidKeyGenerationResult.cs new file mode 100644 index 0000000..68c2462 --- /dev/null +++ b/src/AdsPush.Vapid/VapidKeyGenerationResult.cs @@ -0,0 +1,27 @@ +namespace AdsPush.Vapid +{ + /// + /// Returns from key generation request. + /// + /// + public class VapidKeyGenerationResult + { + public VapidKeyGenerationResult( + string publicLey, + string privateKey) + { + this.PublicLey = publicLey; + this.PrivateKey = privateKey; + } + + /// + /// Public key that's required for client operation. + /// + public string PublicLey { get; } + + /// + /// Private key that should be used in server-side encryption. + /// + public string PrivateKey { get; } + } +} diff --git a/src/AdsPush.Vapid/VapidPushNotificationSender.cs b/src/AdsPush.Vapid/VapidPushNotificationSender.cs new file mode 100644 index 0000000..8acf50d --- /dev/null +++ b/src/AdsPush.Vapid/VapidPushNotificationSender.cs @@ -0,0 +1,181 @@ +using System; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Threading; +using System.Threading.Tasks; +using AdsPush.Abstraction; +using AdsPush.Abstraction.Settings; +using AdsPush.Abstraction.Vapid; +using AdsPush.Vapid.Extensions; +using AdsPush.Vapid.Util; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Newtonsoft.Json.Serialization; + +namespace AdsPush.Vapid +{ + public class VapidPushNotificationSender : IVapidPushNotificationSender + { + private const long DefaultTtl = 43200; + private readonly HttpClient _client; + private readonly AdsPushVapidSettings _adsPushVapidSettings; + + private readonly JsonSerializerSettings _jsonSerializerSettings = new JsonSerializerSettings + { + ContractResolver = new CamelCasePropertyNamesContractResolver(), + NullValueHandling = NullValueHandling.Ignore, + DefaultValueHandling = DefaultValueHandling.Ignore + }; + + public VapidPushNotificationSender( + AdsPushVapidSettings adsPushVapidSettings, + HttpClient client) + { + this._client = client; + this._adsPushVapidSettings = adsPushVapidSettings; + } + + /// + public Task SendAsync( + VapidSubscription subscription, + string payload, + CancellationToken cancellationToken = default) + { + return this.SendBaseAsync( + subscription, + payload, + cancellationToken); + } + + /// + public Task SendAsync( + VapidSubscription subscription, + VapidRequest payload, + CancellationToken cancellationToken = default) + { + var jsonPayload = JsonConvert.SerializeObject(payload, this._jsonSerializerSettings); + return this.SendBaseAsync( + subscription, + jsonPayload, + cancellationToken); + } + + /// + public async Task SendAsync( + string subscriptionJson, + AdsPushBasicSendPayload payload, + CancellationToken cancellationToken = default) + { + var subscription = VapidSubscription.FromSubscriptionJson(subscriptionJson); + var vapidRequest = payload.CreateRequest(); + var jsonPayload = JsonConvert.SerializeObject(vapidRequest, this._jsonSerializerSettings); + var result = await this.SendBaseAsync( + subscription, + jsonPayload, + cancellationToken); + + if (!result.IsSuccess) + { + throw result.Error.CreateException(); + } + } + + private async Task SendHttpRequestAsync( + VapidSubscription subscription, + string payload, + CancellationToken cancellationToken) + { + var request = new HttpRequestMessage(HttpMethod.Post, subscription.Endpoint); + var encryptedPayload = Encryptor.Encrypt( + subscription.P256dh, + subscription.Auth, + payload); + + request.Content = new ByteArrayContent(encryptedPayload.Payload); + request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream"); + request.Content.Headers.ContentLength = encryptedPayload.Payload.Length; + request.Content.Headers.ContentEncoding.Add("aesgcm"); + + var uri = new Uri(subscription.Endpoint); + var audience = uri.Scheme + @"://" + uri.Host; + var vapidHeaders = VapidHelper.GetVapidHeaders(audience, + this._adsPushVapidSettings.Subject, + this._adsPushVapidSettings.PublicKey, + this._adsPushVapidSettings.PrivateKey); + + var cryptoKeyHeader = @"dh=" + encryptedPayload.Base64EncodePublicKey() + @";" + vapidHeaders["Crypto-Key"]; + request.Headers.Add("Crypto-Key", cryptoKeyHeader); + request.Headers.Add("Encryption", "salt=" + encryptedPayload.Base64EncodeSalt()); + request.Headers.Add("Authorization", vapidHeaders["Authorization"]); + request.Headers.Add("TTL", this.GetTtl(payload).ToString()); + + return await this._client.SendAsync(request, cancellationToken); + } + + private async Task SendBaseAsync( + VapidSubscription subscription, + string jsonPayload, + CancellationToken cancellationToken) + { + if (!this.ValidateSubscription(subscription)) + { + return new VapidResponse(false, new VapidError(VapidErrorReasonCode.InvalidToken, null)); + } + + var response = await this.SendHttpRequestAsync( + subscription, + jsonPayload, + cancellationToken); + + if (response.IsSuccessStatusCode) + { + return new VapidResponse(true, null); + } + + var reasonCode = this.GetVapidErrorReasonCode(response); + return new VapidResponse(false, new VapidError(reasonCode, response)); + } + + private long GetTtl( + string jsonPayload) + { + var ttlString = JObject.Parse(jsonPayload)["TTL"]?.ToString(); + if (!string.IsNullOrEmpty(ttlString) && long.TryParse(ttlString, out var ttlLong) && ttlLong > 0) + { + return ttlLong; + } + + return DefaultTtl; + } + + private bool ValidateSubscription( + VapidSubscription subscription) + { + return Uri.IsWellFormedUriString(subscription.Endpoint, UriKind.Absolute) + && !string.IsNullOrEmpty(subscription.P256dh) + && !string.IsNullOrEmpty(subscription.Auth); + } + + private VapidErrorReasonCode GetVapidErrorReasonCode( + HttpResponseMessage response) + { + switch (response.StatusCode) + { + case HttpStatusCode.BadRequest: + return VapidErrorReasonCode.InvalidArgument; + case HttpStatusCode.Unauthorized: + case HttpStatusCode.Forbidden: + return VapidErrorReasonCode.InvalidAuthConfiguration; + + case HttpStatusCode.NotFound: + return VapidErrorReasonCode.InvalidToken; + + case HttpStatusCode.ServiceUnavailable: + return VapidErrorReasonCode.ServiceUnavailable; + default: + return VapidErrorReasonCode.UnknownError; + } + } + } +} diff --git a/src/AdsPush.Vapid/VapidPushNotificationSenderFactory.cs b/src/AdsPush.Vapid/VapidPushNotificationSenderFactory.cs new file mode 100644 index 0000000..ca11bbc --- /dev/null +++ b/src/AdsPush.Vapid/VapidPushNotificationSenderFactory.cs @@ -0,0 +1,56 @@ +using System; +using System.Collections.Concurrent; +using System.Net.Http; +using AdsPush.Abstraction.Settings; +using AdsPush.Vapid.Settings; + +namespace AdsPush.Vapid +{ + public class VapidPushNotificationSenderFactory : IVapidPushNotificationSenderFactory + { + public VapidPushNotificationSenderFactory( + VapidSettingsSection vapidSettingsSection, + HttpClient httpClient) + { + this._vapidPushNotificationSenders = new ConcurrentDictionary(); + this._settings = vapidSettingsSection ?? new VapidSettingsSection(); + this._httpClient = httpClient; + } + + public VapidPushNotificationSenderFactory() + { + this._vapidPushNotificationSenders = new ConcurrentDictionary(); + } + + private readonly HttpClient _httpClient; + private readonly ConcurrentDictionary _vapidPushNotificationSenders; + private readonly VapidSettingsSection _settings; + + /// + public IVapidPushNotificationSender GetSender( + string appName) + { + return this._vapidPushNotificationSenders.GetOrAdd(appName, this.ValueFactory); + } + + private VapidPushNotificationSender ValueFactory( + string arg) + { + if (!this._settings.ContainsKey(arg)) + { + throw new ArgumentException($"{arg} is not defined in settings!"); + } + + var settings = this._settings[arg]; + return new VapidPushNotificationSender(settings, this._httpClient); + } + + /// + public IVapidPushNotificationSender GetSender( + string appName, + AdsPushVapidSettings vapidSettings) + { + return this._vapidPushNotificationSenders.GetOrAdd(appName, new VapidPushNotificationSender(vapidSettings, this._httpClient)); + } + } +} diff --git a/src/AdsPush.Vapid/VapidSubscription.cs b/src/AdsPush.Vapid/VapidSubscription.cs new file mode 100644 index 0000000..77583fb --- /dev/null +++ b/src/AdsPush.Vapid/VapidSubscription.cs @@ -0,0 +1,97 @@ +using Newtonsoft.Json.Linq; + +namespace AdsPush.Vapid +{ + /// + /// Represents a VAPID subscription used for sending push notifications. + /// + public class VapidSubscription + { + /// + /// Creates a new instance of using the provided parameters. + /// + /// The URL endpoint of the subscription. + /// The p256dh value of the subscription. + /// The auth value of the subscription. + /// A new instance. + public static VapidSubscription FromParameters( + string endpoint, + string p256dh, + string auth) + { + return new VapidSubscription( + endpoint, + p256dh, + auth); + } + + /// + /// Creates a new instance of by parsing the subscription JSON. + /// + /// The JSON representation of the subscription. + /// A new instance. + public static VapidSubscription FromSubscriptionJson( + string subscriptionJson) + { + var jsonObject = JObject.Parse(subscriptionJson); + var endpoint = jsonObject.SelectToken("endpoint")?.ToString(); + var p256dh = jsonObject.SelectToken("keys.p256dh")?.ToString(); + var auth = jsonObject.SelectToken("keys.auth")?.ToString(); + return new VapidSubscription( + endpoint, + p256dh, + auth); + } + + /// + /// Creates a new instance of by parsing the base64-encoded subscription JSON. + /// + /// The base64-encoded JSON representation of the subscription. + /// A new instance. + public static VapidSubscription FromBase64EncodedSubscriptionJson( + string base64EncodedSubscriptionJson) + { + var base64EncodedBytes = System.Convert.FromBase64String(base64EncodedSubscriptionJson); + var json = System.Text.Encoding.UTF8.GetString(base64EncodedBytes); + return FromSubscriptionJson(json); + } + + private VapidSubscription( + string endpoint, + string p256dh, + string auth) + { + this.Endpoint = endpoint; + this.P256dh = p256dh; + this.Auth = auth; + } + + public string ToAdsPushToken() + { + return new JObject() + { + ["endpoint"] = this.Endpoint, + ["keys"] = new JObject() + { + ["auth"] = this.Auth, + ["p256dh"] = this.P256dh + } + }.ToString(); + } + + /// + /// Gets the URL endpoint of the subscription. + /// + public string Endpoint { get; } + + /// + /// Gets the p256dh value of the subscription. + /// + public string P256dh { get; } + + /// + /// Gets the auth value of the subscription. + /// + public string Auth { get; } + } +} diff --git a/src/AdsPush/AdsPush.csproj b/src/AdsPush/AdsPush.csproj index 88477da..a154ba9 100644 --- a/src/AdsPush/AdsPush.csproj +++ b/src/AdsPush/AdsPush.csproj @@ -5,9 +5,9 @@ netstandard2.0 AdsPush Anil Dursun SENEL - push;APNS;server-side-push-library;Firebase;Apple;FCM;ios-push;android-push;notofication;push-notification;push notification + push;APNS;server-side-push-library;Firebase;Apple;FCM;ios-push;android-push;notofication;VAPID;WebPush;push-notification;push notification - AdsPush is the server-side push notification library that fully supports APNS(Apple Push Notification Service) and FCM (Firebase Cloud Messaging) features and works with the the most common .NET platorms. It puts togetter good abtraction, easy using and full support for advanced use cases. + AdsPush is the server-side push notification library that fully supports APNS(Apple Push Notification Service), FCM (Firebase Cloud Messaging) and VAPID WebPush features and works with the the most common .NET platorms. It puts togetter good abtraction, easy using and full support for advanced use cases. true logo.png @@ -25,6 +25,7 @@ + diff --git a/src/AdsPush/AdsPushSender.cs b/src/AdsPush/AdsPushSender.cs index 5b8b053..00550cc 100644 --- a/src/AdsPush/AdsPushSender.cs +++ b/src/AdsPush/AdsPushSender.cs @@ -4,11 +4,12 @@ using AdsPush.Abstraction; using AdsPush.APNS; using AdsPush.Firebase; +using AdsPush.Vapid; namespace AdsPush { /// - /// + /// /// public class AdsPushSender : IAdsPushSender { @@ -16,9 +17,10 @@ public class AdsPushSender : IAdsPushSender private readonly IAdsPushConfigurationProvider _adsPushConfigurationProvider; private readonly IFirebasePushNotificationSenderFactory _firebasePushNotificationSenderFactory; private readonly IApplePushNotificationSenderFactory _applePushNotificationSenderFactory; + private readonly IVapidPushNotificationSenderFactory _vapidPushNotificationSenderFactory; /// - /// + /// /// /// /// @@ -28,12 +30,14 @@ public class AdsPushSender : IAdsPushSender string appName, IAdsPushConfigurationProvider adsPushConfigurationProvider, IFirebasePushNotificationSenderFactory firebasePushNotificationSenderFactory, - IApplePushNotificationSenderFactory applePushNotificationSenderFactory) + IApplePushNotificationSenderFactory applePushNotificationSenderFactory, + IVapidPushNotificationSenderFactory vapidPushNotificationSenderFactory) { this._appName = appName; this._adsPushConfigurationProvider = adsPushConfigurationProvider; this._firebasePushNotificationSenderFactory = firebasePushNotificationSenderFactory; this._applePushNotificationSenderFactory = applePushNotificationSenderFactory; + this._vapidPushNotificationSenderFactory = vapidPushNotificationSenderFactory; } @@ -96,6 +100,23 @@ await this._firebasePushNotificationSenderFactory payload, cancellationToken); + break; + case AdsPushProvider.VapidWebPush: + if (settings.Vapid is null) + { + throw new AdsPushException( + $"Settings are not configured for target platform {target}. Configure VAPID to be able to proceed.", + AdsPushErrorType.InvalidAuthConfiguration, + null); + } + + await this._vapidPushNotificationSenderFactory + .GetSender(this._appName, settings.Vapid) + .SendAsync( + pushToken, + payload, + cancellationToken); + break; default: throw new NotSupportedException($"Target {target} is not supported by Framework"); @@ -105,7 +126,7 @@ await this._firebasePushNotificationSenderFactory /// public IApplePushNotificationSender GetApnsSender() { - return _applePushNotificationSenderFactory.GetSender(this._appName); + return this._applePushNotificationSenderFactory.GetSender(this._appName); } /// @@ -113,5 +134,11 @@ public IFirebasePushNotificationSender GetFirebaseSender() { return this._firebasePushNotificationSenderFactory.GetSender(this._appName); } + + /// + public IVapidPushNotificationSender GetVapidSender() + { + return this._vapidPushNotificationSenderFactory.GetSender(this._appName); + } } } diff --git a/src/AdsPush/AdsPushSenderBuilder.cs b/src/AdsPush/AdsPushSenderBuilder.cs index f350efb..99f2b1f 100644 --- a/src/AdsPush/AdsPushSenderBuilder.cs +++ b/src/AdsPush/AdsPushSenderBuilder.cs @@ -6,6 +6,8 @@ using AdsPush.APNS.Settings; using AdsPush.Firebase; using AdsPush.Firebase.Settings; +using AdsPush.Vapid; +using AdsPush.Vapid.Settings; namespace AdsPush { @@ -16,15 +18,16 @@ public class AdsPushSenderBuilder { private readonly AdsPushAppSettings _adsPushAppSettings; private HttpClient _apnsHttpClient; - + private HttpClient _vapidHttpClient; + /// - /// + /// /// public AdsPushSenderBuilder() { - _adsPushAppSettings = new AdsPushAppSettings(); + this._adsPushAppSettings = new AdsPushAppSettings(); } - + /// /// Use to configure APNS for sender. /// @@ -35,13 +38,24 @@ public AdsPushSenderBuilder() AdsPushAPNSSettings settings, HttpClient httpClient = null) { - _adsPushAppSettings.Apns = settings; - _adsPushAppSettings.TargetMappings.Add(AdsPushTarget.Ios, AdsPushProvider.Apns); - _apnsHttpClient = httpClient ?? new HttpClient(); - + this._adsPushAppSettings.Apns = settings; + this._adsPushAppSettings.TargetMappings.Add(AdsPushTarget.Ios, AdsPushProvider.Apns); + this._apnsHttpClient = httpClient ?? new HttpClient(); + + return this; + } + + public AdsPushSenderBuilder ConfigureVapid( + AdsPushVapidSettings settings, + HttpClient httpClient = null) + { + this._adsPushAppSettings.Vapid = settings; + this._adsPushAppSettings.TargetMappings.Add(AdsPushTarget.BrowserAndPwa, AdsPushProvider.VapidWebPush); + this._vapidHttpClient = httpClient ?? new HttpClient(); + return this; } - + /// /// Use to configure Firebase Cloud Messaging for sender. /// @@ -52,15 +66,15 @@ public AdsPushSenderBuilder() AdsPushFirebaseSettings settings, params AdsPushTarget[] targets) { - _adsPushAppSettings.Firebase = settings; + this._adsPushAppSettings.Firebase = settings; foreach (var target in targets) { - _adsPushAppSettings.TargetMappings[target] = AdsPushProvider.Firebase; + this._adsPushAppSettings.TargetMappings[target] = AdsPushProvider.Firebase; } - + return this; } - + /// /// Build the configured sender. /// @@ -68,31 +82,41 @@ public AdsPushSenderBuilder() public IAdsPushSender BuildSender() { var appName = Guid.NewGuid().ToString(); - var provider = new BasicAdsPushConfigurationProvider(_adsPushAppSettings); - - var apnsFactory = _adsPushAppSettings.Apns != null + var provider = new BasicAdsPushConfigurationProvider(this._adsPushAppSettings); + + var apnsFactory = this._adsPushAppSettings.Apns != null ? new ApplePushNotificationSenderFactory(new APNSSettingsSection { { - appName, _adsPushAppSettings.Apns + appName, this._adsPushAppSettings.Apns } - }, _apnsHttpClient) + }, this._apnsHttpClient) : null; - - var firebaseFactory = _adsPushAppSettings.Firebase != null + + var firebaseFactory = this._adsPushAppSettings.Firebase != null ? new FirebasePushNotificationSenderFactory(new FirebaseAppSettingsSection { { - appName, _adsPushAppSettings.Firebase + appName, this._adsPushAppSettings.Firebase } }) : null; - + + var vapidFactory = this._adsPushAppSettings.Vapid != null + ? new VapidPushNotificationSenderFactory(new VapidSettingsSection() + { + { + appName, this._adsPushAppSettings.Vapid + } + }, this._vapidHttpClient) + : null; + return new AdsPushSender( appName, provider, firebaseFactory, - apnsFactory); + apnsFactory, + vapidFactory); } } -} \ No newline at end of file +} diff --git a/src/AdsPush/AdsPushSenderFactory.cs b/src/AdsPush/AdsPushSenderFactory.cs index 70a1cc8..5363fda 100644 --- a/src/AdsPush/AdsPushSenderFactory.cs +++ b/src/AdsPush/AdsPushSenderFactory.cs @@ -1,8 +1,8 @@ using System.Collections.Concurrent; using AdsPush.Abstraction; -using AdsPush.Abstraction.Settings; using AdsPush.APNS; using AdsPush.Firebase; +using AdsPush.Vapid; namespace AdsPush { @@ -15,25 +15,29 @@ public class AdsPushSenderFactory : IAdsPushSenderFactory private readonly IAdsPushConfigurationProvider _adsPushConfigurationProvider; private readonly IApplePushNotificationSenderFactory _applePushNotificationSenderFactory; private readonly IFirebasePushNotificationSenderFactory _firebasePushNotificationSenderFactory; - + private readonly IVapidPushNotificationSenderFactory _vapidPushNotificationSenderFactory; + /// - /// + /// /// /// /// /// + /// public AdsPushSenderFactory( IAdsPushConfigurationProvider adsPushConfigurationProvider, IApplePushNotificationSenderFactory applePushNotificationSenderFactory, - IFirebasePushNotificationSenderFactory firebasePushNotificationSenderFactory) + IFirebasePushNotificationSenderFactory firebasePushNotificationSenderFactory, + IVapidPushNotificationSenderFactory vapidPushNotificationSenderFactory) { this._senders = new ConcurrentDictionary(); this._adsPushConfigurationProvider = adsPushConfigurationProvider; this._applePushNotificationSenderFactory = applePushNotificationSenderFactory; this._firebasePushNotificationSenderFactory = firebasePushNotificationSenderFactory; + this._vapidPushNotificationSenderFactory = vapidPushNotificationSenderFactory; } - + /// public IAdsPushSender GetSender( string appName) @@ -43,7 +47,8 @@ public class AdsPushSenderFactory : IAdsPushSenderFactory new AdsPushSender( appName, this._adsPushConfigurationProvider, this._firebasePushNotificationSenderFactory, - this._applePushNotificationSenderFactory)); + this._applePushNotificationSenderFactory, + this._vapidPushNotificationSenderFactory)); } } } diff --git a/src/AdsPush/Extensions/BuilderExtension.cs b/src/AdsPush/Extensions/BuilderExtension.cs index 3b15c30..aa86bf7 100644 --- a/src/AdsPush/Extensions/BuilderExtension.cs +++ b/src/AdsPush/Extensions/BuilderExtension.cs @@ -4,12 +4,13 @@ using AdsPush.Abstraction.Settings; using AdsPush.APNS.Extensions; using AdsPush.Firebase.Extensions; +using AdsPush.Vapid.Extensions; using Microsoft.Extensions.Configuration; namespace AdsPush.Extensions { /// - /// + /// /// public static class BuilderExtension { @@ -26,6 +27,7 @@ public static class BuilderExtension { services.AddFirebaseCloudMessagingServiceFactory(); services.AddAppleNotificationServiceFactory(); + services.AddVapidNotificationServiceFactory(); services.AddSingleton(); services.AddSingleton(); services.Configure(settings); @@ -45,6 +47,7 @@ public static class BuilderExtension { services.AddFirebaseCloudMessagingServiceFactory(); services.AddAppleNotificationServiceFactory(); + services.AddVapidNotificationServiceFactory(); services.AddSingleton(); services.AddSingleton(); services.Configure(configuration.GetSection("AdsPush")); @@ -63,6 +66,7 @@ public static class BuilderExtension { services.AddFirebaseCloudMessagingServiceFactory(); services.AddAppleNotificationServiceFactory(); + services.AddVapidNotificationServiceFactory(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/AdsPush/IAdsPushSender.cs b/src/AdsPush/IAdsPushSender.cs index 6f5bad7..197c94a 100644 --- a/src/AdsPush/IAdsPushSender.cs +++ b/src/AdsPush/IAdsPushSender.cs @@ -1,8 +1,10 @@ +using System; using System.Threading; using System.Threading.Tasks; using AdsPush.Abstraction; using AdsPush.APNS; using AdsPush.Firebase; +using AdsPush.Vapid; namespace AdsPush { @@ -27,15 +29,21 @@ public interface IAdsPushSender CancellationToken cancellationToken = default); /// - /// Use to access whole platform specific options for APNS. + /// Use to access whole platform specific options for APNS. /// /// IApplePushNotificationSender GetApnsSender(); - + /// - /// Use to access whole platform specific options for Firebase. + /// Use to access whole platform specific options for Firebase. /// /// IFirebasePushNotificationSender GetFirebaseSender(); + + /// + /// Use to access whole platform specific options for Vapid. + /// + /// + IVapidPushNotificationSender GetVapidSender(); } }