pimeys / rust-web-push

A Web Push library for Rust
Apache License 2.0
114 stars 21 forks source link

How to use this create. #43

Closed Dygear closed 1 year ago

Dygear commented 1 year ago

Going over the files trying to figure out how to actually implement crate in production. Looking at the src/vapid/builder.rs the comment section for that is as follows.

Private key generation:

openssl ecparam -name prime256v1 -genkey -noout -out private.pem

To derive a public key out of generated private key:

openssl ec -in private.pem -pubout -out vapid_public.pem

To get the byte form of the public key for the JavaScript client:

openssl ec -in private.pem -text -noout -conv_form uncompressed

... or a base64 encoded string, which the client should convert into byte form before using:

openssl ec -in private.pem -pubout -outform DER|tail -c 65|base64|tr '/+' '_-'|tr -d '\n'

How exactly should we get the applicationServerKey from this? This is kinda of a huge point that is never explained.

const appKey = ''; // What should go here on the that we deliver to the client?
const pushSubscription = await sw.pushManager.subscribe({
    userVisibleOnly: true,
    applicationServerKey: urlBase64ToUint8Array(appKey)
});
andyblarblar commented 1 year ago

You actually have the answer in your comment:

... or a base64 encoded string, which the client should convert into byte form before using:

openssl ec -in private.pem -pubout -outform DER|tail -c 65|base64|tr '/+' '_-'|tr -d '\n'

That is the base64 encoded key for that field. It's up to you to figure out how to get it onto your frontend (that much is out of scope for this crate), but generally you would have an endpoint on your rust api that returns the public key as base64, which is then decoded in JavaScript (this is what that urlbase64 to uint8 array function is for) and added to that field of push sub options you mentioned.

Dygear commented 1 year ago

You how what happens when you look at code for so long that and it all stars to blur together. That and my general lack of expertise in this area. But I think that is going to be a problem for many people who try to use this crate. Perhaps it would be worth putting a "create_vapid_keys.sh" file in the root of the project so people don't have to fumble around with it like I did.

Dygear commented 1 year ago
root@s76:~/rust-web-push# openssl ecparam -name prime256v1 -genkey -noout -out private.pem
root@s76:~/rust-web-push# openssl ec -in private.pem -pubout -out vapid_public.pem
read EC key
writing EC key
root@s76:~/rust-web-push# openssl ec -in private.pem -text -noout -conv_form uncompressed
read EC key
Private-Key: (256 bit)
priv:
    7f:36:1c:a0:1b:f6:ac:f5:02:5c:2b:82:c1:e0:53:
    b3:03:62:37:01:e7:84:e3:75:ed:e9:57:42:59:b4:
    7c:11
pub:
    04:ae:ed:dd:e1:04:be:32:fb:eb:16:aa:43:70:a4:
    89:68:2f:91:54:a4:8e:17:61:dc:e1:0c:9e:ac:aa:
    97:c3:ab:8d:4f:8f:0e:04:e1:cc:9f:e6:90:ae:48:
    ed:0a:84:2e:60:95:bb:4f:91:10:5c:5a:cd:fe:61:
    a3:b5:82:8a:5a
ASN1 OID: prime256v1
NIST CURVE: P-256
root@s76:~/rust-web-push# openssl ec -in private.pem -pubout -outform DER|tail -c 65|base64|tr '/+' '_-'|tr -d '\n'
read EC key
writing EC key
BK7t3eEEvjL76xaqQ3CkiWgvkVSkjhdh3OEMnqyql8OrjU-PDgThzJ_mkK5I7QqELmCVu0-REFxazf5ho7WCilo=root@s76:~/rust-web-push# 

So I take this value ... BK7t3eEEvjL76xaqQ3CkiWgvkVSkjhdh3OEMnqyql8OrjU-PDgThzJ_mkK5I7QqELmCVu0-REFxazf5ho7WCilo= and I put it into my website ...

<!DOCTYPE html>
<html>
    <head>
        <title>WebPush</title>
        <meta charset="utf-8" />
    </head>
    <body>
        <input type="checkbox" id="enablePush" disable onChange="subscribeToPush()">
        <label for="enablePush">Push Notifications</label>
        <script defer>
            var sw;

            let setup = (async () => {
                if ('serviceWorker' in navigator) {
                    console.log("Service Workers are Available");
                    sw = await navigator.serviceWorker.getRegistration();
                    if (!sw) {
                        console.log("Service Workers Not Installed ... Registering.")
                        sw = await navigator.serviceWorker.register('sw.js');
                        if (!sw) {
                            console.log("Failed");
                        } else {
                            console.log("Success");
                        }
                    }
                }
            });

            async function subscribeToPush() {
                let subscription = await sw.pushManager.subscribe({
                    userVisibleOnly: true,
                    applicationServerKey: urlBase64ToUint8Array('BK7t3eEEvjL76xaqQ3CkiWgvkVSkjhdh3OEMnqyql8OrjU-PDgThzJ_mkK5I7QqELmCVu0-REFxazf5ho7WCilo')
                });
                sendSubscriptionToServer(subscription);
            }

            async function sendSubscriptionToServer(subscription) {
                console.log(JSON.stringify(subscription));
                let sub = await fetch('/push/subscribe.php', {
                    credentials: 'include',
                    method: 'POST',
                    headers: {
                        'Content-Type': 'application/x-www-form-urlencoded'
                    },
                    body: "json=" + JSON.stringify(subscription),
                });
                console.dir(sub);
            }

            function urlBase64ToUint8Array(base64String) {
                const padding = '='.repeat((4 - base64String.length % 4) % 4);
                const base64 = (base64String + padding).replace(/\-/g, '+').replace(/_/g, '/');

                const rawData = window.atob(base64);
                const outputArray = new Uint8Array(rawData.length);

                for (let i = 0; i < rawData.length; ++i) {
                    outputArray[i] = rawData.charCodeAt(i);
                }
                return outputArray;
            }

            window.onload = setup();
        </script>
    </body>
</html>

And when I interact with the page by pressing a button for requesting notifications I get the following payload back and dump that into the examples/test.json file.

{"endpoint":"https://fcm.googleapis.com/fcm/send/fZBpgjDSI20:APA91bEs3zWvytHvzPo9SOsw1FRwnbJrkmsjKO1v0aIyGEoZT5JwDJ3VvzqqUYdpD9brztgrnpuyy2igiopf8svGV2bp-XjtmHZ38dXQuwVnoc9lCAS8JWL8CtdZJ1TnvO9S1gZANAgi","expirationTime":null,"keys":{"p256dh":"BBsFDqEhutkwMQ0GbtEg2PZQd5WvdyIkteBfZs_9nRNkNm2CZ015jUMN5WxRHrSJubt_PrZF80JMR1XPyfHRCeA","auth":"CUf2XXAFteLfBoDxgfrLYg"}}
cargo run --example simple_send -- -f examples/test.json -p "It Works!"
    Finished dev [unoptimized + debuginfo] target(s) in 0.06s
     Running `target/debug/examples/simple_send -f examples/test.json -p 'It Works'\!''`
Error: InvalidCryptoKeys
root@s76:~/rust-web-push# cargo run --example simple_send -- -f examples/test.json -p "It Works!"
    Finished dev [unoptimized + debuginfo] target(s) in 0.06s
     Running `target/debug/examples/simple_send -f examples/test.json -p 'It Works'\!''`
Error: InvalidCryptoKeys
root@s76:~/rust-web-push# cargo run --example simple_send -- -f examples/test.json -p "It Works!"
    Finished dev [unoptimized + debuginfo] target(s) in 0.06s
     Running `target/debug/examples/simple_send -f examples/test.json -p 'It Works'\!''`
Error: Unauthorized
root@s76:~/rust-web-push# cargo run --example simple_send -- -f examples/test.json -p "It Works!"
    Finished dev [unoptimized + debuginfo] target(s) in 0.06s
     Running `target/debug/examples/simple_send -f examples/test.json -p 'It Works'\!''`
Error: Unauthorized

First attempt with with Firefox that wasn't happy. This last example is from Google Chrome (On Linux). None of them seem to want to play ball. I really have no idea what I am doing wrong.

andyblarblar commented 1 year ago

try enabling trace logs for the example and checking those. They will contain the full reponse body of the 401 unauthorised, which generally contains a more complete description of what is invalid.

Dygear commented 1 year ago

So I added extern crate pretty_env_logger; to the top of the examples/simple_send.rs and pretty_env_logger::init(); at the start of the main function. I also added pretty_env_logger = "0.3" to cargo.toml under [dependencies]. I'm going down rabbit hole after rabbit hole. With the terminal on macOS SSH'd into my server, not the one built into VSCode I get the following output:

root@s76:~/rust-web-push# cargo run --example simple_send -- -f examples/test.json -v private.pem -p "It Works"
    Finished dev [unoptimized + debuginfo] target(s) in 0.06s
     Running `target/debug/examples/simple_send -f examples/test.json -v private.pem -p 'It Works'`
Sent: ()

But no message is sent.

This is what I get when I run from terminal inside of VSCode:

root@s76:~/rust-web-push# cargo run --example simple_send -- -f examples/test.json -p "It Works!"
    Finished dev [unoptimized + debuginfo] target(s) in 0.31s
     Running `target/debug/examples/simple_send -f examples/test.json -p 'It Works'\!''`
Error: Other("403")

Looks like VSCode is adding some extra formatting that is breaking the args. But even so, with the normal macOS terminal, while says Sent: () and returns the unit type (more on that later), it doesn't actually produce a push message.

Now if I remove the -p and associated string it's consistent across both the terminal in VSCode and macOS's terminal application.

RUST_LOG="trace" cargo run --example simple_send -- -f examples/test.json -v private.pem

Gets me here:

 TRACE web_push::clients::isahc_client > Response: Response { status: 201, version: HTTP/2.0, headers: {"apns-id": "4AD7E74A-0670-8FB8-8F1E-820EF14330EA"}, body: AsyncBody(?) }
 TRACE web_push::clients::isahc_client > Response status: 201 Created
 TRACE web_push::clients::isahc_client > Body: []
 TRACE web_push::clients::isahc_client > Body text: Ok("")
 TRACE web_push::clients::isahc_client > Response: Ok(())
Sent: ()

So it looks like based on the 201 response, that it did work. Yet I have no push notifications anywhere.

So it has to be a problem with my service worker right?

sw.js

const VERSION = 10;
const expectedCaches = [`static-v${VERSION}`];

self.addEventListener('install', event => {
    console.log(`V${VERSION} Installing…`);

    self.skipWaiting();

    event.waitUntil(
        caches.open(`static-v${VERSION}`).then(cache => {
            cache.add('/css/mimocad.css');
            cache.add('/css/amxmod.css');
            cache.add('/css/light.css');
            cache.add('/css/night.css');
        })
    );
});

self.addEventListener('activate', event => {
    event.waitUntil(
        caches.keys().then(keys => Promise.all(
            keys.map(key => {
                // delete any caches that aren't in expectedCaches
                if (!expectedCaches.includes(key)) {
                    return caches.delete(key);
                }
            })
        )).then(() => {
            console.log('Ready to handle fetches!');
        })
    );
});

self.addEventListener('fetch', event => {
    const url = new URL(event.request.url);

    if (url.origin == location.origin) {
        if (url.pathname == '/css/mimocad.css') {
            event.respondWith(caches.match('/css/mimocad.css'));
        } else if (url.pathname == '/css/amxmod.css') {
            event.respondWith(caches.match('/css/amxmod.css'));
        } else if (url.pathname == '/css/light.css') {
            event.respondWith(caches.match('/css/light.css'));
        } else if (url.pathname == '/css/night.css') {
            event.respondWith(caches.match('/css/night.css'));
        }
    }
});

self.addEventListener('message', event => {
    console.log('Message event');
    console.dir(event);
});

// https://web.dev/push-notifications-handling-messages/
self.addEventListener('push', event => {
    console.log("Push event");
    console.dir(event);

    const note = event.data.json();
    const chain = self.registration.showNotification(note.title);

    event.waitUntil(chain);
});

// https://web.dev/push-notifications-notification-behaviour/
self.addEventListener('notificationclick', event => {
    console.log("notificationclick event");
    console.dir(event);

    if (!event.action) {
        // Was a normal notification click
        console.log('Notification Click.');
        return;
    }
});

While I am here, clippy complains about the final line being a unit type:

this let-binding has unit value
for further information visit https://rust-lang.github.io/rust-clippy/master/index.html#let_unit_value
`#[warn(clippy::let_unit_value)]` on by defaultclippy[Click for full compiler diagnostic](rust-analyzer-diagnostics-view:/diagnostic%20message%20%5B0%5D?0#file%3A%2F%2F%2Froot%2Frust-web-push%2Fexamples%2Fsimple_send.rs)
simple_send.rs(88, 5): omit the `let` binding: `client.send(builder.build()?).await?;`
andyblarblar commented 1 year ago

It's definitly going to be an issue with your service worker, this crate is working fine. I'm afraid I can't help much on that front, since SW are fairly nuanced and it's not an area I'm very familiar with.

Here's a refrence for debugging the service worker: https://hacks.mozilla.org/2016/03/debugging-service-workers-and-push-with-firefox-devtools/

Dygear commented 1 year ago

For what it's worth, here is my sw.js so people can use that as a basis for building there own.

const VERSION = 13;
const expectedCaches = [`static-v${VERSION}`];

self.addEventListener('install', event => {
    console.log(`V${VERSION} Installing…`);

    self.skipWaiting();

    event.waitUntil(
        caches.open(`static-v${VERSION}`).then(cache => {
            cache.add('/css/light.css');
            cache.add('/css/night.css');
        })
    );
});

self.addEventListener('activate', event => {
    event.waitUntil(
        caches.keys().then(keys => Promise.all(
            keys.map(key => {
                // delete any caches that aren't in expectedCaches
                if (!expectedCaches.includes(key)) {
                    return caches.delete(key);
                }
            })
        )).then(() => {
            console.log('Ready to handle fetches!');
        })
    );
});

self.addEventListener('fetch', event => {
    const url = new URL(event.request.url);

    if (url.origin == location.origin) {
        return caches.match(url.pathname) || fetch(url.pathname);
    }
});

self.addEventListener('push', event => {
    const json = event.data.json();
    event.waitUntil(
        self.registration.showNotification(json.title, {
            body: json.body,
            icon: '/images/Logo.png',
            tag: json.title,
        })
    );
});

// https://web.dev/push-notifications-notification-behaviour/
self.addEventListener('notificationclick', event => {
    console.log("notificationclick event");
    console.dir(event);

    const notification = event.notification;
    const action = event.action;
    if (action === 'close') {
        notification.close();
    } else {
        clients.openWindow('https://domain.tld/');
    }
});

And in a <script defer> tag on the site (under the body tag FWIW) I have this:

const BASE64_PUBKEY = ''; // Put your pubkey here from the `openssl ec -in private.pem -pubout -outform DER|tail -c 65|base64|tr '/+' '_-'|tr -d '\n'` step.
var sw;

let setup = (async () => {
    if ('serviceWorker' in navigator) {
        console.log("Service Workers are Available");
        sw = await navigator.serviceWorker.register('sw.js');
        if (!sw) {
            console.log("Failed");
        } else {
            console.log("Success");
        }
    }

    navigator.serviceWorker.ready.then(reg => {
        enablePush.disabled = false;
        reg.pushManager.getSubscription().then(sub => {
            if (sub != undefined) {
                enablePush.checked = true;
                console.log(JSON.stringify(sub));
            }
        })
    });
});

async function subscribeToPush() {
    let subscription = await sw.pushManager.subscribe({
        userVisibleOnly: true,
        applicationServerKey: urlBase64ToUint8Array(BASE64_PUBKEY)
    });
    sendSubscriptionToServer(subscription);
}

async function sendSubscriptionToServer(subscription) {
    console.log(JSON.stringify(subscription));
    let sub = await fetch('/push/subscribe.php', {
        credentials: 'include',
        method: 'POST',
        headers: {
            'Content-Type': 'application/x-www-form-urlencoded'
        },
        body: "json=" + JSON.stringify(subscription),
    });
    console.dir(sub);
}

function urlBase64ToUint8Array(base64String) {
    const padding = '='.repeat((4 - base64String.length % 4) % 4);
    const base64 = (base64String + padding).replace(/\-/g, '+').replace(/_/g, '/');

    const rawData = window.atob(base64);
    const outputArray = new Uint8Array(rawData.length);

    for (let i = 0; i < rawData.length; ++i) {
        outputArray[i] = rawData.charCodeAt(i);
    }
    return outputArray;
}

window.onload = setup();

Link this manifest.json file in the HTML document like this <link "rel"="manifest", "href"="manifest.json" /> in the head.


{
    "id": "/",
    "start_url": "/",
    "short_name": "Short Name Here.",
    "name": "Full name here.",
    "icons": [{
        "src": "/images/Logo/192x192.png",
        "sizes": "192x192",
        "type": "image/png"
    }, {
        "src": "/images/Logo/512x512.png",
        "sizes": "512x512",
        "type": "image/png"
    }],
    "background_color": "#262830",
    "display": "standalone",
    "scope": "/",
    "theme_color": "#262830",
    "description": "What your site is about"
}```