├── .formatter.exs ├── .github ├── FUNDING.yml ├── dependabot.yml └── workflows │ ├── static.yml │ └── test.yml ├── .gitignore ├── LICENSE ├── README.md ├── config ├── config.exs └── test.exs ├── example ├── app.webmanifest ├── favicon.ico ├── index.html ├── main.js └── service-worker.js ├── lib ├── mix │ └── tasks │ │ └── generate_vapid_keys.ex ├── web_push_elixir.ex └── web_push_elixir │ ├── application.ex │ └── mock_server.ex ├── mix.exs ├── mix.lock └── test ├── generate_vapid_keys_test.exs ├── test_helper.exs └── web_push_elixir_test.exs /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: trueChazza -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: mix 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | time: "10:00" 8 | open-pull-requests-limit: 10 -------------------------------------------------------------------------------- /.github/workflows/static.yml: -------------------------------------------------------------------------------- 1 | # Simple workflow for deploying static content to GitHub Pages 2 | name: Deploy static content to Pages 3 | 4 | on: 5 | # Runs on pushes targeting the default branch 6 | push: 7 | branches: ["main"] 8 | 9 | # Allows you to run this workflow manually from the Actions tab 10 | workflow_dispatch: 11 | 12 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages 13 | permissions: 14 | contents: read 15 | pages: write 16 | id-token: write 17 | 18 | # Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. 19 | # However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. 20 | concurrency: 21 | group: "pages" 22 | cancel-in-progress: false 23 | 24 | jobs: 25 | # Single deploy job since we're just deploying 26 | deploy: 27 | environment: 28 | name: github-pages 29 | url: ${{ steps.deployment.outputs.page_url }} 30 | runs-on: ubuntu-latest 31 | steps: 32 | - name: Checkout 33 | uses: actions/checkout@v3 34 | - name: Setup Pages 35 | uses: actions/configure-pages@v3 36 | - name: Upload artifact 37 | uses: actions/upload-pages-artifact@v2 38 | with: 39 | # Upload entire repository 40 | path: './example' 41 | - name: Deploy to GitHub Pages 42 | id: deployment 43 | uses: actions/deploy-pages@v2 44 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: 6 | - '**' 7 | 8 | env: 9 | MIX_ENV: test 10 | 11 | jobs: 12 | 13 | test: 14 | name: OTP ${{matrix.otp}} / Elixir ${{matrix.elixir}} 15 | runs-on: ubuntu-latest 16 | strategy: 17 | matrix: 18 | otp: [ '26', '25', '24' ] 19 | elixir: [ '1.15' ] 20 | steps: 21 | - uses: actions/checkout@v3 22 | - uses: erlef/setup-beam@v1 23 | with: 24 | otp-version: ${{matrix.otp}} 25 | elixir-version: ${{matrix.elixir}} 26 | - run: mix deps.get 27 | - run: mix test 28 | - run: mix coveralls.json 29 | - uses: codecov/codecov-action@v3 30 | env: 31 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where third-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | web_push_elixir-*.tar 24 | 25 | # Temporary files, for example, from tests. 26 | /tmp/ 27 | 28 | /.idea/ 29 | *.iml -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Midarr Labs 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # Web Push Elixir 3 | 4 | Simple web push library for Elixir 5 | 6 | 7 | Test Status 8 | 9 | 10 | Code Coverage 11 | 12 | 13 | Hex Version 14 | 15 | 16 | Hex Docs 17 | 18 | 19 | 20 | ## Prerequisities 21 | 22 | * Elixir 1.15 23 | * OTP 24 / 25 / 26 24 | 25 | ## Installation 26 | 27 | 1. Add `web_push_elixir` to your list of dependencies in `mix.exs`: 28 | 29 | ```elixir 30 | def deps do 31 | [ 32 | {:web_push_elixir, "~> 0.4.0"} 33 | ] 34 | end 35 | ``` 36 | 37 | 2. Run mix command to generate your Vapid public and private keys: 38 | 39 | ```commandline 40 | mix generate.vapid.keys 41 | ``` 42 | 43 | 3. Set config for your generated keys: 44 | 45 | ```elixir 46 | config :web_push_elixir, 47 | vapid_public_key: "someVapidPublicKey", 48 | vapid_private_key: "someVapidPrivateKey", 49 | vapid_subject: "mailto:admin@email.com" 50 | ``` 51 | 52 | ## Usage 53 | 54 | `WebPushElixir` provides a simple `send_notification/2` that takes 2 arguments: 55 | 56 | * `subscription`: the subscription information received from the client - [example demo](https://midarrlabs.github.io/web-push-elixir/) 57 | * `message`: the message string. 58 | 59 | ```elixir 60 | subscription = '{"endpoint":"https://some-push-service","keys":{"p256dh":"BNcRdreALRFXTkOOUHK1EtK2wtaz5Ry4YfYCA_0QTpQtUbVlUls0VJXg7A8u-Ts1XbjhazAkj7I99e8QcYP7DkM=","auth":"tBHItJI5svbpez7KI4CCXg=="}}' 61 | message = "Some message" 62 | 63 | WebPushElixir.send_notification(subscription, message) 64 | ``` 65 | 66 | For more information on how to subscribe a client, permission UX and more - take a look at [https://web.dev/notifications/](https://web.dev/notifications/) 67 | 68 | ## Run tests 69 | 70 | ```commandline 71 | mix test 72 | ``` 73 | 74 | ## License 75 | 76 | Web Push Elixir is open-sourced software licensed under the [MIT license](LICENSE). 77 | 78 | 79 | ## Credits 80 | 81 | Heavily inspired by [elixir-web-push-encryption](https://github.com/danhper/elixir-web-push-encryption) 82 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | if Mix.env() == :test do 4 | import_config "test.exs" 5 | end -------------------------------------------------------------------------------- /config/test.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | config :web_push_elixir, 4 | vapid_public_key: "someVapidPublicKey", 5 | vapid_private_key: "someVapidPrivateKey", 6 | vapid_subject: "mailto:admin@email.com" -------------------------------------------------------------------------------- /example/app.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Web Push Elixir", 3 | "display": "standalone" 4 | } -------------------------------------------------------------------------------- /example/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/midarrlabs/web-push-elixir/092ac293b5f98781d28b74a1e6c8afe9dcab82d1/example/favicon.ico -------------------------------------------------------------------------------- /example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Web Push Elixir 7 | 8 | 9 | 10 | 11 | 12 |
13 |
14 |

Web Push Elixir

15 |

Client Subscription

16 |

Example client subscription for push notifications

17 |
18 | 19 | 20 |
21 |
22 | 23 |
24 |
25 |
26 |
27 | 28 | 29 | -------------------------------------------------------------------------------- /example/main.js: -------------------------------------------------------------------------------- 1 | const message = document.getElementById('message') 2 | 3 | navigator.serviceWorker 4 | .register(`${ window.location.pathname }service-worker.js`) 5 | .then(registration => { 6 | message.innerHTML += '

Service worker successfully registered

' 7 | }) 8 | .catch(err => { 9 | message.innerHTML += `

Unable to register service worker - ${err}

` 10 | }) 11 | 12 | const request = document.getElementById('request') 13 | const subscribe = document.getElementById('subscribe') 14 | 15 | request.addEventListener('click', event => { 16 | Notification.requestPermission() 17 | .then(permission => { 18 | message.innerHTML += `

Permission ${permission}

` 19 | }) 20 | }); 21 | 22 | subscribe.addEventListener('click', event => { 23 | navigator.serviceWorker.ready 24 | .then(registration => { 25 | 26 | registration.pushManager.subscribe({ 27 | userVisibleOnly: true, 28 | applicationServerKey: 'BDntLA3k5K1tsrFOXXAuS_9Ey30jxy-R2CAosC2DOQnTs8LpQGxpTEx3AcPXinVYFFpJI6tT_RJC8pHgUsdbhOk' 29 | }) 30 | .then(pushSubscription => { 31 | message.innerHTML += `

Received PushSubscription:

` 32 | message.innerHTML += `

${JSON.stringify(pushSubscription)}

` 33 | }) 34 | .catch(err => { 35 | registration.pushManager.getSubscription().then(subscription => { 36 | if (subscription !== null) { 37 | subscription 38 | .unsubscribe() 39 | .then(successful => { 40 | message.innerHTML += '

Unsubscribed from existing subscription, please subscribe again

' 41 | }) 42 | .catch(err => { 43 | message.innerHTML += `

Failed to unsubscribe from existing subscription - ${err}

` 44 | }) 45 | } else { 46 | message.innerHTML += '

No subscription found

' 47 | } 48 | }) 49 | }) 50 | }) 51 | }) -------------------------------------------------------------------------------- /example/service-worker.js: -------------------------------------------------------------------------------- 1 | self.addEventListener('push', event => { 2 | if (event.data) { 3 | const promiseChain = self.registration.showNotification(event.data.text()); 4 | 5 | event.waitUntil(promiseChain); 6 | } else { 7 | console.log('This push event has no data.'); 8 | } 9 | }); -------------------------------------------------------------------------------- /lib/mix/tasks/generate_vapid_keys.ex: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.Generate.Vapid.Keys do 2 | @moduledoc "The mix task: `mix help generate.vapid.keys`" 3 | use Mix.Task 4 | 5 | @shortdoc "Generate vapid keys" 6 | def run(_args) do 7 | {public_key, private_key} = :crypto.generate_key(:ecdh, :prime256v1) 8 | 9 | %{ 10 | vapid_public_key: Base.url_encode64(public_key, padding: false), 11 | vapid_private_key: Base.url_encode64(private_key, padding: false), 12 | vapid_subject: "mailto:admin@email.com" 13 | } 14 | |> IO.inspect 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/web_push_elixir.ex: -------------------------------------------------------------------------------- 1 | defmodule WebPushElixir do 2 | @moduledoc """ 3 | Module to send web push notifications with an encrypted payload. 4 | """ 5 | 6 | defp url_encode(string) do 7 | Base.url_encode64(string, padding: false) 8 | end 9 | 10 | defp url_decode(string) do 11 | Base.url_decode64!(string, padding: false) 12 | end 13 | 14 | defp hmac_based_key_derivation_function(salt, initial_keying_material, info, length) do 15 | pseudo_random_key = 16 | :crypto.mac_init(:hmac, :sha256, salt) 17 | |> :crypto.mac_update(initial_keying_material) 18 | |> :crypto.mac_final() 19 | 20 | :crypto.mac_init(:hmac, :sha256, pseudo_random_key) 21 | |> :crypto.mac_update(info) 22 | |> :crypto.mac_update(<<1>>) 23 | |> :crypto.mac_final() 24 | |> :binary.part(0, length) 25 | end 26 | 27 | defp encrypt_payload(message, p256dh, auth) do 28 | client_public_key = url_decode(p256dh) 29 | client_auth_secret = url_decode(auth) 30 | 31 | salt = :crypto.strong_rand_bytes(16) 32 | 33 | {local_public_key, local_private_key} = :crypto.generate_key(:ecdh, :prime256v1) 34 | 35 | shared_secret = :crypto.compute_key(:ecdh, client_public_key, local_private_key, :prime256v1) 36 | 37 | pseudo_random_key = 38 | hmac_based_key_derivation_function( 39 | client_auth_secret, 40 | shared_secret, 41 | "Content-Encoding: auth" <> <<0>>, 42 | 32 43 | ) 44 | 45 | context = 46 | <<0, byte_size(client_public_key)::unsigned-big-integer-size(16)>> <> 47 | client_public_key <> 48 | <> <> local_public_key 49 | 50 | content_encryption_key_info = "Content-Encoding: aesgcm" <> <<0>> <> "P-256" <> context 51 | 52 | content_encryption_key = 53 | hmac_based_key_derivation_function(salt, pseudo_random_key, content_encryption_key_info, 16) 54 | 55 | nonce = 56 | hmac_based_key_derivation_function( 57 | salt, 58 | pseudo_random_key, 59 | "Content-Encoding: nonce" <> <<0>> <> "P-256" <> context, 60 | 12 61 | ) 62 | 63 | padded_message = <<0::unsigned-big-integer-size(16)>> <> :binary.copy(<<0>>, 0) <> message 64 | 65 | {cipher_text, cipher_tag} = 66 | :crypto.crypto_one_time_aead( 67 | :aes_128_gcm, 68 | content_encryption_key, 69 | nonce, 70 | padded_message, 71 | <<>>, 72 | true 73 | ) 74 | 75 | %{ciphertext: cipher_text <> cipher_tag, salt: salt, local_public_key: local_public_key} 76 | end 77 | 78 | defp sign_json_web_token(endpoint, vapid_public_key, vapid_private_key) do 79 | json_web_token = 80 | JOSE.JWT.from_map(%{ 81 | aud: URI.parse(endpoint).scheme <> "://" <> URI.parse(endpoint).host, 82 | exp: DateTime.to_unix(DateTime.utc_now()) + 12 * 3600, 83 | sub: Application.get_env(:web_push_elixir, :vapid_subject) 84 | }) 85 | 86 | json_web_key = 87 | JOSE.JWK.from_key( 88 | {:ECPrivateKey, 1, vapid_private_key, {:namedCurve, {1, 2, 840, 10045, 3, 1, 7}}, 89 | vapid_public_key, nil} 90 | ) 91 | 92 | {%{alg: :jose_jws_alg_ecdsa}, signed_json_web_token} = 93 | JOSE.JWS.compact(JOSE.JWT.sign(json_web_key, %{"alg" => "ES256"}, json_web_token)) 94 | 95 | signed_json_web_token 96 | end 97 | 98 | @doc """ 99 | Sends a web push notification with an encrypted payload. 100 | 101 | ## Arguments 102 | 103 | * `subscription` the subscription information received from the client. Accepted example: `'{"endpoint":"https://some-push-service","keys":{"p256dh":"BNcRdreALRFXTkOOUHK1EtK2wtaz5Ry4YfYCA_0QTpQtUbVlUls0VJXg7A8u-Ts1XbjhazAkj7I99e8QcYP7DkM=","auth":"tBHItJI5svbpez7KI4CCXg=="}}'` 104 | * `message` the message string. 105 | 106 | ## Return value 107 | 108 | Returns the result of `HTTPoison.post` 109 | """ 110 | def send_notification(subscription, message) do 111 | vapid_public_key = url_decode(Application.get_env(:web_push_elixir, :vapid_public_key)) 112 | vapid_private_key = url_decode(Application.get_env(:web_push_elixir, :vapid_private_key)) 113 | 114 | %{"endpoint" => endpoint, "keys" => %{"p256dh" => p256dh, "auth" => auth}} = 115 | Jason.decode!(subscription) 116 | 117 | encrypted_payload = encrypt_payload(message, p256dh, auth) 118 | 119 | signed_json_web_token = 120 | sign_json_web_token(endpoint, vapid_public_key, vapid_private_key) 121 | 122 | HTTPoison.post(endpoint, encrypted_payload.ciphertext, %{ 123 | "Authorization" => "WebPush #{signed_json_web_token}", 124 | "Content-Encoding" => "aesgcm", 125 | "Content-Length" => "#{byte_size(encrypted_payload.ciphertext)}", 126 | "Content-Type" => "application/octet-stream", 127 | "Crypto-Key" => 128 | "dh=#{url_encode(encrypted_payload.local_public_key)};p256ecdsa=#{url_encode(vapid_public_key)}", 129 | "Encryption" => "salt=#{url_encode(encrypted_payload.salt)}", 130 | "TTL" => "60" 131 | }) 132 | end 133 | end 134 | -------------------------------------------------------------------------------- /lib/web_push_elixir/application.ex: -------------------------------------------------------------------------------- 1 | defmodule WebPushElixir.Application do 2 | use Application 3 | 4 | def start(_type, _args) do 5 | children = [ 6 | {DynamicSupervisor, name: WebPushElixir.DynamicSupervisor} 7 | ] 8 | 9 | opts = [strategy: :one_for_one, name: WebPushElixir.Supervisor] 10 | Supervisor.start_link(children, opts) 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/web_push_elixir/mock_server.ex: -------------------------------------------------------------------------------- 1 | defmodule WebPushElixir.MockServer do 2 | use Plug.Router 3 | 4 | plug(:match) 5 | plug(:dispatch) 6 | 7 | post "/some-push-service" do 8 | conn 9 | |> Plug.Conn.send_resp(200, "ok") 10 | end 11 | 12 | get "/" do 13 | conn 14 | |> put_resp_header("content-type", "text/html; charset=utf-8") 15 | |> Plug.Conn.send_file(200, "./example/index.html") 16 | end 17 | 18 | get "/app.webmanifest" do 19 | conn 20 | |> put_resp_header("content-type", "application/manifest+json") 21 | |> Plug.Conn.send_file(200, "./example/app.webmanifest") 22 | end 23 | 24 | get "/main.js" do 25 | conn 26 | |> put_resp_header("content-type", "application/x-javascript") 27 | |> Plug.Conn.send_file(200, "./example/main.js") 28 | end 29 | 30 | get "/service-worker.js" do 31 | conn 32 | |> put_resp_header("content-type", "application/x-javascript") 33 | |> Plug.Conn.send_file(200, "./example/service-worker.js") 34 | end 35 | 36 | get "/web-push-elixir/service-worker.js" do 37 | conn 38 | |> put_resp_header("content-type", "application/x-javascript") 39 | |> Plug.Conn.send_file(200, "./example/service-worker.js") 40 | end 41 | 42 | get "/favicon.ico" do 43 | conn 44 | |> put_resp_header("content-type", "image/x-icon") 45 | |> Plug.Conn.send_file(200, "./example/favicon.ico") 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule WebPushElixir.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :web_push_elixir, 7 | version: "0.4.0", 8 | elixir: "~> 1.15", 9 | start_permanent: Mix.env() == :prod, 10 | deps: deps(), 11 | description: "Simple web push for Elixir", 12 | package: [ 13 | licenses: ["MIT"], 14 | links: %{"GitHub" => "https://github.com/midarrlabs/web-push-elixir"} 15 | ], 16 | test_coverage: [tool: ExCoveralls] 17 | ] 18 | end 19 | 20 | # Run "mix help compile.app" to learn about applications. 21 | def application do 22 | [ 23 | mod: {WebPushElixir.Application, []}, 24 | extra_applications: [:logger] 25 | ] 26 | end 27 | 28 | # Run "mix help deps" to learn about dependencies. 29 | defp deps do 30 | [ 31 | {:ex_doc, "~> 0.27", only: :dev, runtime: false}, 32 | {:excoveralls, "~> 0.10", only: :test}, 33 | {:jose, "~> 1.11"}, 34 | {:jason, "~> 1.4"}, 35 | {:plug, "~> 1.14"}, 36 | {:plug_cowboy, "~> 2.0"}, 37 | {:httpoison, "~> 2.0"} 38 | ] 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "certifi": {:hex, :certifi, "2.14.0", "ed3bef654e69cde5e6c022df8070a579a79e8ba2368a00acf3d75b82d9aceeed", [:rebar3], [], "hexpm", "ea59d87ef89da429b8e905264fdec3419f84f2215bb3d81e07a18aac919026c3"}, 3 | "cowboy": {:hex, :cowboy, "2.13.0", "09d770dd5f6a22cc60c071f432cd7cb87776164527f205c5a6b0f24ff6b38990", [:make, :rebar3], [{:cowlib, ">= 2.14.0 and < 3.0.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, ">= 1.8.0 and < 3.0.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "e724d3a70995025d654c1992c7b11dbfea95205c047d86ff9bf1cda92ddc5614"}, 4 | "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"}, 5 | "cowlib": {:hex, :cowlib, "2.14.0", "623791c56c1cc9df54a71a9c55147a401549917f00a2e48a6ae12b812c586ced", [:make, :rebar3], [], "hexpm", "0af652d1550c8411c3b58eed7a035a7fb088c0b86aff6bc504b0bc3b7f791aa2"}, 6 | "earmark_parser": {:hex, :earmark_parser, "1.4.42", "f23d856f41919f17cd06a493923a722d87a2d684f143a1e663c04a2b93100682", [:mix], [], "hexpm", "6915b6ca369b5f7346636a2f41c6a6d78b5af419d61a611079189233358b8b8b"}, 7 | "ex_doc": {:hex, :ex_doc, "0.36.1", "4197d034f93e0b89ec79fac56e226107824adcce8d2dd0a26f5ed3a95efc36b1", [:mix], [{:earmark_parser, "~> 1.4.42", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "d7d26a7cf965dacadcd48f9fa7b5953d7d0cfa3b44fa7a65514427da44eafd89"}, 8 | "excoveralls": {:hex, :excoveralls, "0.18.5", "e229d0a65982613332ec30f07940038fe451a2e5b29bce2a5022165f0c9b157e", [:mix], [{:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "523fe8a15603f86d64852aab2abe8ddbd78e68579c8525ae765facc5eae01562"}, 9 | "hackney": {:hex, :hackney, "1.23.0", "55cc09077112bcb4a69e54be46ed9bc55537763a96cd4a80a221663a7eafd767", [:rebar3], [{:certifi, "~> 2.14.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "6cd1c04cd15c81e5a493f167b226a15f0938a84fc8f0736ebe4ddcab65c0b44e"}, 10 | "httpoison": {:hex, :httpoison, "2.2.3", "a599d4b34004cc60678999445da53b5e653630651d4da3d14675fedc9dd34bd6", [:mix], [{:hackney, "~> 1.21", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "fa0f2e3646d3762fdc73edb532104c8619c7636a6997d20af4003da6cfc53e53"}, 11 | "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, 12 | "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, 13 | "jose": {:hex, :jose, "1.11.10", "a903f5227417bd2a08c8a00a0cbcc458118be84480955e8d251297a425723f83", [:mix, :rebar3], [], "hexpm", "0d6cd36ff8ba174db29148fc112b5842186b68a90ce9fc2b3ec3afe76593e614"}, 14 | "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, 15 | "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, 16 | "makeup_erlang": {:hex, :makeup_erlang, "1.0.1", "c7f58c120b2b5aa5fd80d540a89fdf866ed42f1f3994e4fe189abebeab610839", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "8a89a1eeccc2d798d6ea15496a6e4870b75e014d1af514b1b71fa33134f57814"}, 17 | "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, 18 | "mime": {:hex, :mime, "2.0.6", "8f18486773d9b15f95f4f4f1e39b710045fa1de891fada4516559967276e4dc2", [:mix], [], "hexpm", "c9945363a6b26d747389aac3643f8e0e09d30499a138ad64fe8fd1d13d9b153e"}, 19 | "mimerl": {:hex, :mimerl, "1.3.0", "d0cd9fc04b9061f82490f6581e0128379830e78535e017f7780f37fea7545726", [:rebar3], [], "hexpm", "a1e15a50d1887217de95f0b9b0793e32853f7c258a5cd227650889b38839fe9d"}, 20 | "nimble_parsec": {:hex, :nimble_parsec, "1.4.0", "51f9b613ea62cfa97b25ccc2c1b4216e81df970acd8e16e8d1bdc58fef21370d", [:mix], [], "hexpm", "9c565862810fb383e9838c1dd2d7d2c437b3d13b267414ba6af33e50d2d1cf28"}, 21 | "parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"}, 22 | "plug": {:hex, :plug, "1.16.1", "40c74619c12f82736d2214557dedec2e9762029b2438d6d175c5074c933edc9d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a13ff6b9006b03d7e33874945b2755253841b238c34071ed85b0e86057f8cddc"}, 23 | "plug_cowboy": {:hex, :plug_cowboy, "2.7.3", "1304d36752e8bdde213cea59ef424ca932910a91a07ef9f3874be709c4ddb94b", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "77c95524b2aa5364b247fa17089029e73b951ebc1adeef429361eab0bb55819d"}, 24 | "plug_crypto": {:hex, :plug_crypto, "2.1.0", "f44309c2b06d249c27c8d3f65cfe08158ade08418cf540fd4f72d4d6863abb7b", [:mix], [], "hexpm", "131216a4b030b8f8ce0f26038bc4421ae60e4bb95c5cf5395e1421437824c4fa"}, 25 | "ranch": {:hex, :ranch, "2.2.0", "25528f82bc8d7c6152c57666ca99ec716510fe0925cb188172f41ce93117b1b0", [:make, :rebar3], [], "hexpm", "fa0b99a1780c80218a4197a59ea8d3bdae32fbff7e88527d7d8a4787eff4f8e7"}, 26 | "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, 27 | "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, 28 | "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"}, 29 | } 30 | -------------------------------------------------------------------------------- /test/generate_vapid_keys_test.exs: -------------------------------------------------------------------------------- 1 | defmodule GenerateVapidKeysTest do 2 | use ExUnit.Case 3 | 4 | test "it should generate" do 5 | assert %{ 6 | vapid_public_key: <<_vapid_public_key::binary>>, 7 | vapid_private_key: <<_vapid_private_key::binary>>, 8 | vapid_subject: "mailto:admin@email.com" 9 | } = Mix.Tasks.Generate.Vapid.Keys.run([]) 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | 3 | DynamicSupervisor.start_child(WebPushElixir.DynamicSupervisor,{Plug.Cowboy, scheme: :http, plug: WebPushElixir.MockServer, options: [port: 4040]}) 4 | -------------------------------------------------------------------------------- /test/web_push_elixir_test.exs: -------------------------------------------------------------------------------- 1 | defmodule WebPushElixirTest do 2 | use ExUnit.Case 3 | 4 | @subscription '{"endpoint":"http://localhost:4040/some-push-service","keys":{"p256dh":"BNcRdreALRFXTkOOUHK1EtK2wtaz5Ry4YfYCA_0QTpQtUbVlUls0VJXg7A8u-Ts1XbjhazAkj7I99e8QcYP7DkM=","auth":"tBHItJI5svbpez7KI4CCXg=="}}' 5 | 6 | test "it should send notification" do 7 | %{ 8 | vapid_public_key: vapid_public_key, 9 | vapid_private_key: vapid_private_key, 10 | vapid_subject: vapid_subject 11 | } = Mix.Tasks.Generate.Vapid.Keys.run([]) 12 | 13 | Application.put_env(:web_push_elixir, :vapid_public_key, vapid_public_key) 14 | 15 | Application.put_env(:web_push_elixir, :vapid_private_key, vapid_private_key) 16 | 17 | Application.put_env(:web_push_elixir, :vapid_subject, vapid_subject) 18 | 19 | {:ok, response} = WebPushElixir.send_notification(@subscription, "some message") 20 | 21 | assert [ 22 | {"Authorization", "WebPush " <> <<_jwt::binary>>}, 23 | {"Content-Encoding", "aesgcm"}, 24 | {"Content-Length", "30"}, 25 | {"Content-Type", "application/octet-stream"}, 26 | {"Crypto-Key", <<_crypto_keys::binary>>}, 27 | {"Encryption", "salt=" <> <<_salt::binary>>}, 28 | {"TTL", "60"} 29 | ] = response.request.headers 30 | 31 | assert <<_body::binary>> = response.request.body 32 | end 33 | 34 | test "it should have index headers" do 35 | {:ok, response} = HTTPoison.get(~c"http://localhost:4040") 36 | 37 | assert [ 38 | {"cache-control", "max-age=0, private, must-revalidate"}, 39 | {"content-length", "1578"}, 40 | {"content-type", "text/html; charset=utf-8"}, 41 | {"date", <<_date::binary>>}, 42 | {"server", "Cowboy"} 43 | ] = response.headers 44 | end 45 | 46 | test "it should have mainfest headers" do 47 | {:ok, response} = HTTPoison.get(~c"http://localhost:4040/app.webmanifest") 48 | 49 | assert [ 50 | {"cache-control", "max-age=0, private, must-revalidate"}, 51 | {"content-length", "58"}, 52 | {"content-type", "application/manifest+json"}, 53 | {"date", <<_date::binary>>}, 54 | {"server", "Cowboy"} 55 | ] = response.headers 56 | end 57 | 58 | test "it should have main js headers" do 59 | {:ok, response} = HTTPoison.get(~c"http://localhost:4040/main.js") 60 | 61 | assert [ 62 | {"cache-control", "max-age=0, private, must-revalidate"}, 63 | {"content-length", "1911"}, 64 | {"content-type", "application/x-javascript"}, 65 | {"date", <<_date::binary>>}, 66 | {"server", "Cowboy"} 67 | ] = response.headers 68 | end 69 | 70 | test "it should have service worker headers" do 71 | {:ok, response} = HTTPoison.get(~c"http://localhost:4040/service-worker.js") 72 | 73 | assert [ 74 | {"cache-control", "max-age=0, private, must-revalidate"}, 75 | {"content-length", "262"}, 76 | {"content-type", "application/x-javascript"}, 77 | {"date", <<_date::binary>>}, 78 | {"server", "Cowboy"} 79 | ] = response.headers 80 | end 81 | 82 | test "it should have static service worker headers" do 83 | {:ok, response} = HTTPoison.get(~c"http://localhost:4040/web-push-elixir/service-worker.js") 84 | 85 | assert [ 86 | {"cache-control", "max-age=0, private, must-revalidate"}, 87 | {"content-length", "262"}, 88 | {"content-type", "application/x-javascript"}, 89 | {"date", <<_date::binary>>}, 90 | {"server", "Cowboy"} 91 | ] = response.headers 92 | end 93 | 94 | test "it should have favicon headers" do 95 | {:ok, response} = HTTPoison.get(~c"http://localhost:4040/favicon.ico") 96 | 97 | assert [ 98 | {"cache-control", "max-age=0, private, must-revalidate"}, 99 | {"content-length", "1150"}, 100 | {"content-type", "image/x-icon"}, 101 | {"date", <<_date::binary>>}, 102 | {"server", "Cowboy"} 103 | ] = response.headers 104 | end 105 | end 106 | --------------------------------------------------------------------------------