././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1732051714.4938493 pywebpush-2.0.3/0000775000175000017500000000000014717201402014020 5ustar00jrconlinjrconlin././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1728921384.0 pywebpush-2.0.3/CHANGELOG.md0000664000175000017500000000350414703237450015642 0ustar00jrconlinjrconlin# I am terrible at keeping this up-to-date. ## 2.0.1 (2024-10-14) docs: Use License classifiers in pyproject.toml (thanks @sevdog) ## 2.0.0 (2024-01-02) chore: Update to modern python practices * include pyproject.toml file * use python typing * update to use pytest *BREAKING_CHANGE* `Webpusher.encode` will now return a `NoData` exception if no data is present to encode. Chances are you probably won't be impacted by this change since most push messages contain data, but one never knows. This alters the prior behavior where it would return `None`. ## 1.14.0 (2021-07-28) bug: accept all VAPID key instances (thanks @mthu) ## 1.13.0 (2021-03-15) Support requests_session param in webpush fn too (thanks @bwindels) ## 1.12.0 (2021-03-15) chore: library update, remove nose tests ## 1.11.0 (2020-04-29) feat: add `--head` to read headers out of a json file (thanks @braedon) ## 1.10.2 (2020-04-11) bug: update min vapid requirement to 1.7.0 ## 1.10.1 (2019-12-03) feat: use six.text_type instead of six.string_types ## 1.10.0 (2019-08-13) feat: Add `--verbose` flag with some initial commentary bug: Update tests to use latest VAPID version ## 1.9.4 (2019-05-09) bug: update vapid `exp` header if missing or expired ## 0.7.0 (2017-02-14) feat: update to http-ece 0.7.0 (with draft-06 support) feat: Allow empty payloads for send() feat: Add python3 classfiers & python3.6 travis tests feat: Add README.rst bug: change long to int to support python3 ## 0.4.0 (2016-06-05) feat: make python 2.7 / 3.5 polyglot ## 0.3.4 (2016-05-17) bug: make header keys case insenstive ## 0.3.3 (2016-05-17) bug: force key string encoding to utf8 ## 0.3.2 (2016-04-28) bug: fix setup.py issues ## 0.3 (2016-04-27) feat: added travis, normalized directories ## 0.2 (2016-04-27) feat: Added tests, restructured code ## 0.1 (2016-04-25) Initial release ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1703783527.0 pywebpush-2.0.3/CODE_OF_CONDUCT.md0000664000175000017500000000126314543326147016634 0ustar00jrconlinjrconlin# Community Participation Guidelines This repository is governed by Mozilla's code of conduct and etiquette guidelines. For more details, please read the [Mozilla Community Participation Guidelines](https://www.mozilla.org/about/governance/policies/participation/). ## How to Report For more information on how to report violations of the Community Participation Guidelines, please read our '[How to Report](https://www.mozilla.org/about/governance/policies/participation/reporting/)' page. ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1461620276.0 pywebpush-2.0.3/LICENSE0000664000175000017500000004052512707507064015045 0ustar00jrconlinjrconlinMozilla Public License Version 2.0 ================================== 1. Definitions -------------- 1.1. "Contributor" means each individual or legal entity that creates, contributes to the creation of, or owns Covered Software. 1.2. "Contributor Version" means the combination of the Contributions of others (if any) used by a Contributor and that particular Contributor's Contribution. 1.3. "Contribution" means Covered Software of a particular Contributor. 1.4. "Covered Software" means Source Code Form to which the initial Contributor has attached the notice in Exhibit A, the Executable Form of such Source Code Form, and Modifications of such Source Code Form, in each case including portions thereof. 1.5. "Incompatible With Secondary Licenses" means (a) that the initial Contributor has attached the notice described in Exhibit B to the Covered Software; or (b) that the Covered Software was made available under the terms of version 1.1 or earlier of the License, but not also under the terms of a Secondary License. 1.6. "Executable Form" means any form of the work other than Source Code Form. 1.7. "Larger Work" means a work that combines Covered Software with other material, in a separate file or files, that is not Covered Software. 1.8. "License" means this document. 1.9. "Licensable" means having the right to grant, to the maximum extent possible, whether at the time of the initial grant or subsequently, any and all of the rights conveyed by this License. 1.10. "Modifications" means any of the following: (a) any file in Source Code Form that results from an addition to, deletion from, or modification of the contents of Covered Software; or (b) any new file in Source Code Form that contains any Covered Software. 1.11. "Patent Claims" of a Contributor means any patent claim(s), including without limitation, method, process, and apparatus claims, in any patent Licensable by such Contributor that would be infringed, but for the grant of the License, by the making, using, selling, offering for sale, having made, import, or transfer of either its Contributions or its Contributor Version. 1.12. "Secondary License" means either the GNU General Public License, Version 2.0, the GNU Lesser General Public License, Version 2.1, the GNU Affero General Public License, Version 3.0, or any later versions of those licenses. 1.13. "Source Code Form" means the form of the work preferred for making modifications. 1.14. "You" (or "Your") means an individual or a legal entity exercising rights under this License. For legal entities, "You" includes any entity that controls, is controlled by, or is under common control with You. For purposes of this definition, "control" means (a) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (b) ownership of more than fifty percent (50%) of the outstanding shares or beneficial ownership of such entity. 2. License Grants and Conditions -------------------------------- 2.1. Grants Each Contributor hereby grants You a world-wide, royalty-free, non-exclusive license: (a) under intellectual property rights (other than patent or trademark) Licensable by such Contributor to use, reproduce, make available, modify, display, perform, distribute, and otherwise exploit its Contributions, either on an unmodified basis, with Modifications, or as part of a Larger Work; and (b) under Patent Claims of such Contributor to make, use, sell, offer for sale, have made, import, and otherwise transfer either its Contributions or its Contributor Version. 2.2. Effective Date The licenses granted in Section 2.1 with respect to any Contribution become effective for each Contribution on the date the Contributor first distributes such Contribution. 2.3. Limitations on Grant Scope The licenses granted in this Section 2 are the only rights granted under this License. No additional rights or licenses will be implied from the distribution or licensing of Covered Software under this License. Notwithstanding Section 2.1(b) above, no patent license is granted by a Contributor: (a) for any code that a Contributor has removed from Covered Software; or (b) for infringements caused by: (i) Your and any other third party's modifications of Covered Software, or (ii) the combination of its Contributions with other software (except as part of its Contributor Version); or (c) under Patent Claims infringed by Covered Software in the absence of its Contributions. This License does not grant any rights in the trademarks, service marks, or logos of any Contributor (except as may be necessary to comply with the notice requirements in Section 3.4). 2.4. Subsequent Licenses No Contributor makes additional grants as a result of Your choice to distribute the Covered Software under a subsequent version of this License (see Section 10.2) or under the terms of a Secondary License (if permitted under the terms of Section 3.3). 2.5. Representation Each Contributor represents that the Contributor believes its Contributions are its original creation(s) or it has sufficient rights to grant the rights to its Contributions conveyed by this License. 2.6. Fair Use This License is not intended to limit any rights You have under applicable copyright doctrines of fair use, fair dealing, or other equivalents. 2.7. Conditions Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted in Section 2.1. 3. Responsibilities ------------------- 3.1. Distribution of Source Form All distribution of Covered Software in Source Code Form, including any Modifications that You create or to which You contribute, must be under the terms of this License. You must inform recipients that the Source Code Form of the Covered Software is governed by the terms of this License, and how they can obtain a copy of this License. You may not attempt to alter or restrict the recipients' rights in the Source Code Form. 3.2. Distribution of Executable Form If You distribute Covered Software in Executable Form then: (a) such Covered Software must also be made available in Source Code Form, as described in Section 3.1, and You must inform recipients of the Executable Form how they can obtain a copy of such Source Code Form by reasonable means in a timely manner, at a charge no more than the cost of distribution to the recipient; and (b) You may distribute such Executable Form under the terms of this License, or sublicense it under different terms, provided that the license for the Executable Form does not attempt to limit or alter the recipients' rights in the Source Code Form under this License. 3.3. Distribution of a Larger Work You may create and distribute a Larger Work under terms of Your choice, provided that You also comply with the requirements of this License for the Covered Software. If the Larger Work is a combination of Covered Software with a work governed by one or more Secondary Licenses, and the Covered Software is not Incompatible With Secondary Licenses, this License permits You to additionally distribute such Covered Software under the terms of such Secondary License(s), so that the recipient of the Larger Work may, at their option, further distribute the Covered Software under the terms of either this License or such Secondary License(s). 3.4. Notices You may not remove or alter the substance of any license notices (including copyright notices, patent notices, disclaimers of warranty, or limitations of liability) contained within the Source Code Form of the Covered Software, except that You may alter any license notices to the extent required to remedy known factual inaccuracies. 3.5. Application of Additional Terms You may choose to offer, and to charge a fee for, warranty, support, indemnity or liability obligations to one or more recipients of Covered Software. However, You may do so only on Your own behalf, and not on behalf of any Contributor. You must make it absolutely clear that any such warranty, support, indemnity, or liability obligation is offered by You alone, and You hereby agree to indemnify every Contributor for any liability incurred by such Contributor as a result of warranty, support, indemnity or liability terms You offer. You may include additional disclaimers of warranty and limitations of liability specific to any jurisdiction. 4. Inability to Comply Due to Statute or Regulation --------------------------------------------------- If it is impossible for You to comply with any of the terms of this License with respect to some or all of the Covered Software due to statute, judicial order, or regulation then You must: (a) comply with the terms of this License to the maximum extent possible; and (b) describe the limitations and the code they affect. Such description must be placed in a text file included with all distributions of the Covered Software under this License. Except to the extent prohibited by statute or regulation, such description must be sufficiently detailed for a recipient of ordinary skill to be able to understand it. 5. Termination -------------- 5.1. The rights granted under this License will terminate automatically if You fail to comply with any of its terms. However, if You become compliant, then the rights granted under this License from a particular Contributor are reinstated (a) provisionally, unless and until such Contributor explicitly and finally terminates Your grants, and (b) on an ongoing basis, if such Contributor fails to notify You of the non-compliance by some reasonable means prior to 60 days after You have come back into compliance. Moreover, Your grants from a particular Contributor are reinstated on an ongoing basis if such Contributor notifies You of the non-compliance by some reasonable means, this is the first time You have received notice of non-compliance with this License from such Contributor, and You become compliant prior to 30 days after Your receipt of the notice. 5.2. If You initiate litigation against any entity by asserting a patent infringement claim (excluding declaratory judgment actions, counter-claims, and cross-claims) alleging that a Contributor Version directly or indirectly infringes any patent, then the rights granted to You by any and all Contributors for the Covered Software under Section 2.1 of this License shall terminate. 5.3. In the event of termination under Sections 5.1 or 5.2 above, all end user license agreements (excluding distributors and resellers) which have been validly granted by You or Your distributors under this License prior to termination shall survive termination. ************************************************************************ * * * 6. Disclaimer of Warranty * * ------------------------- * * * * Covered Software is provided under this License on an "as is" * * basis, without warranty of any kind, either expressed, implied, or * * statutory, including, without limitation, warranties that the * * Covered Software is free of defects, merchantable, fit for a * * particular purpose or non-infringing. The entire risk as to the * * quality and performance of the Covered Software is with You. * * Should any Covered Software prove defective in any respect, You * * (not any Contributor) assume the cost of any necessary servicing, * * repair, or correction. This disclaimer of warranty constitutes an * * essential part of this License. No use of any Covered Software is * * authorized under this License except under this disclaimer. * * * ************************************************************************ ************************************************************************ * * * 7. Limitation of Liability * * -------------------------- * * * * Under no circumstances and under no legal theory, whether tort * * (including negligence), contract, or otherwise, shall any * * Contributor, or anyone who distributes Covered Software as * * permitted above, be liable to You for any direct, indirect, * * special, incidental, or consequential damages of any character * * including, without limitation, damages for lost profits, loss of * * goodwill, work stoppage, computer failure or malfunction, or any * * and all other commercial damages or losses, even if such party * * shall have been informed of the possibility of such damages. This * * limitation of liability shall not apply to liability for death or * * personal injury resulting from such party's negligence to the * * extent applicable law prohibits such limitation. Some * * jurisdictions do not allow the exclusion or limitation of * * incidental or consequential damages, so this exclusion and * * limitation may not apply to You. * * * ************************************************************************ 8. Litigation ------------- Any litigation relating to this License may be brought only in the courts of a jurisdiction where the defendant maintains its principal place of business and such litigation shall be governed by laws of that jurisdiction, without reference to its conflict-of-law provisions. Nothing in this Section shall prevent a party's ability to bring cross-claims or counter-claims. 9. Miscellaneous ---------------- This License represents the complete agreement concerning the subject matter hereof. If any provision of this License is held to be unenforceable, such provision shall be reformed only to the extent necessary to make it enforceable. Any law or regulation which provides that the language of a contract shall be construed against the drafter shall not be used to construe this License against a Contributor. 10. Versions of the License --------------------------- 10.1. New Versions Mozilla Foundation is the license steward. Except as provided in Section 10.3, no one other than the license steward has the right to modify or publish new versions of this License. Each version will be given a distinguishing version number. 10.2. Effect of New Versions You may distribute the Covered Software under the terms of the version of the License under which You originally received the Covered Software, or under the terms of any subsequent version published by the license steward. 10.3. Modified Versions If you create software not governed by this License, and you want to create a new license for such software, you may create and use a modified version of this License if you rename the license and remove any references to the name of the license steward (except to note that such modified license differs from this License). 10.4. Distributing Source Code Form that is Incompatible With Secondary Licenses If You choose to distribute Source Code Form that is Incompatible With Secondary Licenses under the terms of this version of the License, the notice described in Exhibit B of this License must be attached. Exhibit A - Source Code Form License Notice ------------------------------------------- This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. If it is not possible or desirable to put the notice in a particular file, then You may include the notice in a location (such as a LICENSE file in a relevant directory) where a recipient would be likely to look for such a notice. You may add additional accurate notices of copyright ownership. Exhibit B - "Incompatible With Secondary Licenses" Notice --------------------------------------------------------- This Source Code Form is "Incompatible With Secondary Licenses", as defined by the Mozilla Public License, v. 2.0. ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1703783527.0 pywebpush-2.0.3/MANIFEST.in0000664000175000017500000000013414543326147015567 0ustar00jrconlinjrconlininclude *.md include *.txt include setup.* include LICENSE recursive-include pywebpush *.py ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1732051714.4938493 pywebpush-2.0.3/PKG-INFO0000644000175000017500000002017514717201402015120 0ustar00jrconlinjrconlinMetadata-Version: 2.1 Name: pywebpush Version: 2.0.3 Summary: WebPush publication library Author-email: JR Conlin License: MPL-2.0 Project-URL: Homepage, https://github.com/web-push-libs/pywebpush Keywords: webpush,vapid,notification Classifier: Topic :: Internet :: WWW/HTTP Classifier: Programming Language :: Python :: Implementation :: PyPy Classifier: Programming Language :: Python Classifier: Programming Language :: Python :: 3 Classifier: License :: OSI Approved :: Mozilla Public License 2.0 (MPL 2.0) Description-Content-Type: text/markdown License-File: LICENSE Requires-Dist: aiohttp Requires-Dist: cryptography>=2.6.1 Requires-Dist: http-ece>=1.1.0 Requires-Dist: requests>=2.21.0 Requires-Dist: six>=1.15.0 Requires-Dist: py-vapid>=1.7.0 Provides-Extra: dev Requires-Dist: black; extra == "dev" Requires-Dist: mock; extra == "dev" Requires-Dist: pytest; extra == "dev" # Webpush Data encryption library for Python [![Build Status](https://travis-ci.org/web-push-libs/pywebpush.svg?branch=main)](https://travis-ci.org/web-push-libs/pywebpush) [![Requirements Status](https://requires.io/github/web-push-libs/pywebpush/requirements.svg?branch=main)](https://requires.io/github/web-push-libs/pywebpush/requirements/?branch=main) This library is available on [pypi as pywebpush](https://pypi.python.org/pypi/pywebpush). Source is available on [github](https://github.com/mozilla-services/pywebpush). Please note: This library was designated as a `Critical Project` by PyPi, it is currently maintained by [a single person](https://xkcd.com/2347/). I still accept PRs and Issues, but make of that what you will. ## Installation To work with this repo locally, you'll need to run `python -m venv venv`. Then `venv/bin/pip install --editable .` ## Usage In the browser, the promise handler for [registration.pushManager.subscribe()](https://developer.mozilla.org/en-US/docs/Web/API/PushManager/subscribe) returns a [PushSubscription](https://developer.mozilla.org/en-US/docs/Web/API/PushSubscription) object. This object has a .toJSON() method that will return a JSON object that contains all the info we need to encrypt and push data. As illustration, a `subscription_info` object may look like: ```json { "endpoint": "https://updates.push.services.mozilla.com/push/v1/gAA...", "keys": { "auth": "k8J...", "p256dh": "BOr..." } } ``` How you send the PushSubscription data to your backend, store it referenced to the user who requested it, and recall it when there's a new push subscription update is left as an exercise for the reader. ### Sending Data using `webpush()` One Call In many cases, your code will be sending a single message to many recipients. There's a "One Call" function which will make things easier. ```python from pywebpush import webpush webpush(subscription_info, data, vapid_private_key="Private Key or File Path[1]", vapid_claims={"sub": "mailto:YourEmailAddress"}) ``` This will encode `data`, add the appropriate VAPID auth headers if required and send it to the push server identified in the `subscription_info` block. ##### Parameters _subscription_info_ - The `dict` of the subscription info (described above). _data_ - can be any serial content (string, bit array, serialized JSON, etc), but be sure that your receiving application is able to parse and understand it. (e.g. `data = "Mary had a little lamb."`) _content_type_ - specifies the form of Encryption to use, either `'aes128gcm'` or the deprecated `'aesgcm'`. NOTE that not all User Agents can decrypt `'aesgcm'`, so the library defaults to the RFC 8188 standard form. _vapid_claims_ - a `dict` containing the VAPID claims required for authorization (See [py_vapid](https://github.com/web-push-libs/vapid/tree/master/python) for more details). If `aud` is not specified, pywebpush will attempt to auto-fill from the `endpoint`. If `exp` is not specified or set in the past, it will be set to 12 hours from now. In both cases, the passed `dict` **will be mutated** after the call. _vapid_private_key_ - Either a path to a VAPID EC2 private key PEM file, or a string containing the DER representation. (See [py_vapid](https://github.com/web-push-libs/vapid/tree/master/python) for more details.) The `private_key` may be a base64 encoded DER formatted private key, or the path to an OpenSSL exported private key file. e.g. the output of: ```bash openssl ecparam -name prime256v1 -genkey -noout -out private_key.pem ``` ##### Example ```python from pywebpush import webpush, WebPushException try: webpush( subscription_info={ "endpoint": "https://push.example.com/v1/12345", "keys": { "p256dh": "0123abcde...", "auth": "abc123..." }}, data="Mary had a little lamb, with a nice mint jelly", vapid_private_key="path/to/vapid_private.pem", vapid_claims={ "sub": "mailto:YourNameHere@example.org", } ) except WebPushException as ex: print("I'm sorry, Dave, but I can't do that: {}", repr(ex)) # Mozilla returns additional information in the body of the response. if ex.response is not None and ex.response.json(): extra = ex.response.json() print("Remote service replied with a {}:{}, {}", extra.code, extra.errno, extra.message ) ``` ### Methods If you expect to resend to the same recipient, or have more needs than just sending data quickly, you can pass just `wp = WebPusher(subscription_info)`. This will return a `WebPusher` object. The following methods are available: #### `.send(data, headers={}, ttl=0, gcm_key="", reg_id="", content_encoding="aes128gcm", curl=False, timeout=None)` Send the data using additional parameters. On error, returns a `WebPushException` ##### Parameters _data_ Binary string of data to send _headers_ A `dict` containing any additional headers to send _ttl_ Message Time To Live on Push Server waiting for the client to reconnect (in seconds) _gcm_key_ Google Cloud Messaging key (if using the older GCM push system) This is the API key obtained from the Google Developer Console. _reg_id_ Google Cloud Messaging registration ID (will be extracted from endpoint if not specified) _content_encoding_ ECE content encoding type (defaults to "aes128gcm") _curl_ Do not execute the POST, but return as a `curl` command. This will write the encrypted content to a local file named `encrpypted.data`. This command is meant to be used for debugging purposes. _timeout_ timeout for requests POST query. See [requests documentation](http://docs.python-requests.org/en/master/user/quickstart/#timeouts). ##### Example to send from Chrome using the old GCM mode: ```python WebPusher(subscription_info).send(data, headers, ttl, gcm_key) ``` #### `.encode(data, content_encoding="aes128gcm")` Encode the `data` for future use. On error, returns a `WebPushException` ##### Parameters _data_ Binary string of data to send _content_encoding_ ECE content encoding type (defaults to "aes128gcm") *Note* This will return a `NoData` exception if the data is not present or empty. It is completely valid to send a WebPush notification with no data, but encoding is a no-op in that case. Best not to call it if you don't have data. ##### Example ```python encoded_data = WebPush(subscription_info).encode(data) ``` ## Stand Alone Webpush If you're not really into coding your own solution, there's also a "stand-alone" `pywebpush` command in the ./bin directory. This uses two files: - the _data_ file, which contains the message to send, in whatever form you like. - the _subscription info_ file, which contains the subscription information as JSON encoded data. This is usually returned by the Push `subscribe` method and looks something like: ```json { "endpoint": "https://push...", "keys": { "auth": "ab01...", "p256dh": "aa02..." } } ``` If you're interested in just testing your applications WebPush interface, you could use the Command Line: ```bash ./bin/pywebpush --data stuff_to_send.data --info subscription.info ``` which will encrypt and send the contents of `stuff_to_send.data`. See `./bin/pywebpush --help` for available commands and options. ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1709572026.0 pywebpush-2.0.3/PULL_REQUEST_TEMPLATE.md0000664000175000017500000000035614571377672017652 0ustar00jrconlinjrconlin## Description *_NOTE_*: All commits MUST be signed! See https://docs.github.com/en/github/authenticating-to-github/signing-commits _Describe these changes._ ## Testing _How should reviewers test?_ ## Issue(s) Closes _#IssueNumber_ ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1728920341.0 pywebpush-2.0.3/README.md0000664000175000017500000001635314703235425015316 0ustar00jrconlinjrconlin# Webpush Data encryption library for Python [![Build Status](https://travis-ci.org/web-push-libs/pywebpush.svg?branch=main)](https://travis-ci.org/web-push-libs/pywebpush) [![Requirements Status](https://requires.io/github/web-push-libs/pywebpush/requirements.svg?branch=main)](https://requires.io/github/web-push-libs/pywebpush/requirements/?branch=main) This library is available on [pypi as pywebpush](https://pypi.python.org/pypi/pywebpush). Source is available on [github](https://github.com/mozilla-services/pywebpush). Please note: This library was designated as a `Critical Project` by PyPi, it is currently maintained by [a single person](https://xkcd.com/2347/). I still accept PRs and Issues, but make of that what you will. ## Installation To work with this repo locally, you'll need to run `python -m venv venv`. Then `venv/bin/pip install --editable .` ## Usage In the browser, the promise handler for [registration.pushManager.subscribe()](https://developer.mozilla.org/en-US/docs/Web/API/PushManager/subscribe) returns a [PushSubscription](https://developer.mozilla.org/en-US/docs/Web/API/PushSubscription) object. This object has a .toJSON() method that will return a JSON object that contains all the info we need to encrypt and push data. As illustration, a `subscription_info` object may look like: ```json { "endpoint": "https://updates.push.services.mozilla.com/push/v1/gAA...", "keys": { "auth": "k8J...", "p256dh": "BOr..." } } ``` How you send the PushSubscription data to your backend, store it referenced to the user who requested it, and recall it when there's a new push subscription update is left as an exercise for the reader. ### Sending Data using `webpush()` One Call In many cases, your code will be sending a single message to many recipients. There's a "One Call" function which will make things easier. ```python from pywebpush import webpush webpush(subscription_info, data, vapid_private_key="Private Key or File Path[1]", vapid_claims={"sub": "mailto:YourEmailAddress"}) ``` This will encode `data`, add the appropriate VAPID auth headers if required and send it to the push server identified in the `subscription_info` block. ##### Parameters _subscription_info_ - The `dict` of the subscription info (described above). _data_ - can be any serial content (string, bit array, serialized JSON, etc), but be sure that your receiving application is able to parse and understand it. (e.g. `data = "Mary had a little lamb."`) _content_type_ - specifies the form of Encryption to use, either `'aes128gcm'` or the deprecated `'aesgcm'`. NOTE that not all User Agents can decrypt `'aesgcm'`, so the library defaults to the RFC 8188 standard form. _vapid_claims_ - a `dict` containing the VAPID claims required for authorization (See [py_vapid](https://github.com/web-push-libs/vapid/tree/master/python) for more details). If `aud` is not specified, pywebpush will attempt to auto-fill from the `endpoint`. If `exp` is not specified or set in the past, it will be set to 12 hours from now. In both cases, the passed `dict` **will be mutated** after the call. _vapid_private_key_ - Either a path to a VAPID EC2 private key PEM file, or a string containing the DER representation. (See [py_vapid](https://github.com/web-push-libs/vapid/tree/master/python) for more details.) The `private_key` may be a base64 encoded DER formatted private key, or the path to an OpenSSL exported private key file. e.g. the output of: ```bash openssl ecparam -name prime256v1 -genkey -noout -out private_key.pem ``` ##### Example ```python from pywebpush import webpush, WebPushException try: webpush( subscription_info={ "endpoint": "https://push.example.com/v1/12345", "keys": { "p256dh": "0123abcde...", "auth": "abc123..." }}, data="Mary had a little lamb, with a nice mint jelly", vapid_private_key="path/to/vapid_private.pem", vapid_claims={ "sub": "mailto:YourNameHere@example.org", } ) except WebPushException as ex: print("I'm sorry, Dave, but I can't do that: {}", repr(ex)) # Mozilla returns additional information in the body of the response. if ex.response is not None and ex.response.json(): extra = ex.response.json() print("Remote service replied with a {}:{}, {}", extra.code, extra.errno, extra.message ) ``` ### Methods If you expect to resend to the same recipient, or have more needs than just sending data quickly, you can pass just `wp = WebPusher(subscription_info)`. This will return a `WebPusher` object. The following methods are available: #### `.send(data, headers={}, ttl=0, gcm_key="", reg_id="", content_encoding="aes128gcm", curl=False, timeout=None)` Send the data using additional parameters. On error, returns a `WebPushException` ##### Parameters _data_ Binary string of data to send _headers_ A `dict` containing any additional headers to send _ttl_ Message Time To Live on Push Server waiting for the client to reconnect (in seconds) _gcm_key_ Google Cloud Messaging key (if using the older GCM push system) This is the API key obtained from the Google Developer Console. _reg_id_ Google Cloud Messaging registration ID (will be extracted from endpoint if not specified) _content_encoding_ ECE content encoding type (defaults to "aes128gcm") _curl_ Do not execute the POST, but return as a `curl` command. This will write the encrypted content to a local file named `encrpypted.data`. This command is meant to be used for debugging purposes. _timeout_ timeout for requests POST query. See [requests documentation](http://docs.python-requests.org/en/master/user/quickstart/#timeouts). ##### Example to send from Chrome using the old GCM mode: ```python WebPusher(subscription_info).send(data, headers, ttl, gcm_key) ``` #### `.encode(data, content_encoding="aes128gcm")` Encode the `data` for future use. On error, returns a `WebPushException` ##### Parameters _data_ Binary string of data to send _content_encoding_ ECE content encoding type (defaults to "aes128gcm") *Note* This will return a `NoData` exception if the data is not present or empty. It is completely valid to send a WebPush notification with no data, but encoding is a no-op in that case. Best not to call it if you don't have data. ##### Example ```python encoded_data = WebPush(subscription_info).encode(data) ``` ## Stand Alone Webpush If you're not really into coding your own solution, there's also a "stand-alone" `pywebpush` command in the ./bin directory. This uses two files: - the _data_ file, which contains the message to send, in whatever form you like. - the _subscription info_ file, which contains the subscription information as JSON encoded data. This is usually returned by the Push `subscribe` method and looks something like: ```json { "endpoint": "https://push...", "keys": { "auth": "ab01...", "p256dh": "aa02..." } } ``` If you're interested in just testing your applications WebPush interface, you could use the Command Line: ```bash ./bin/pywebpush --data stuff_to_send.data --info subscription.info ``` which will encrypt and send the contents of `stuff_to_send.data`. See `./bin/pywebpush --help` for available commands and options. ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1732051373.0 pywebpush-2.0.3/README.rst0000664000175000017500000001672714717200655015535 0ustar00jrconlinjrconlinWebpush Data encryption library for Python ========================================== |Build Status| |Requirements Status| This library is available on `pypi as pywebpush `__. Source is available on `github `__. Please note: This library was designated as a ``Critical Project`` by PyPi, it is currently maintained by `a single person `__. I still accept PRs and Issues, but make of that what you will. Installation ------------ You’ll need to run ``python -m venv venv``. Then .. code:: bash venv/bin/pip install -r requirements.txt venv/bin/python -m pip install -e . Usage ----- In the browser, the promise handler for `registration.pushManager.subscribe() `__ returns a `PushSubscription `__ object. This object has a .toJSON() method that will return a JSON object that contains all the info we need to encrypt and push data. As illustration, a ``subscription_info`` object may look like: .. code:: json {"endpoint": "https://updates.push.services.mozilla.com/push/v1/gAA...", "keys": {"auth": "k8J...", "p256dh": "BOr..."}} How you send the PushSubscription data to your backend, store it referenced to the user who requested it, and recall it when there’s a new push subscription update is left as an exercise for the reader. Sending Data using ``webpush()`` One Call ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ In many cases, your code will be sending a single message to many recipients. There’s a “One Call” function which will make things easier. .. code:: python from pywebpush import webpush webpush(subscription_info, data, vapid_private_key="Private Key or File Path[1]", vapid_claims={"sub": "mailto:YourEmailAddress"}) This will encode ``data``, add the appropriate VAPID auth headers if required and send it to the push server identified in the ``subscription_info`` block. **Parameters** *subscription_info* - The ``dict`` of the subscription info (described above). *data* - can be any serial content (string, bit array, serialized JSON, etc), but be sure that your receiving application is able to parse and understand it. (e.g. ``data = "Mary had a little lamb."``) *content_type* - specifies the form of Encryption to use, either ``'aes128gcm'`` or the deprecated ``'aesgcm'``. NOTE that not all User Agents can decrypt ``'aesgcm'``, so the library defaults to the RFC 8188 standard form. *vapid_claims* - a ``dict`` containing the VAPID claims required for authorization (See `py_vapid `__ for more details). If ``aud`` is not specified, pywebpush will attempt to auto-fill from the ``endpoint``. *vapid_private_key* - Either a path to a VAPID EC2 private key PEM file, or a string containing the DER representation. (See `py_vapid `__ for more details.) The ``private_key`` may be a base64 encoded DER formatted private key, or the path to an OpenSSL exported private key file. e.g. the output of: .. code:: bash openssl ecparam -name prime256v1 -genkey -noout -out private_key.pem **Example** .. code:: python from pywebpush import webpush, WebPushException try: webpush( subscription_info={ "endpoint": "https://push.example.com/v1/12345", "keys": { "p256dh": "0123abcde...", "auth": "abc123..." }}, data="Mary had a little lamb, with a nice mint jelly", vapid_private_key="path/to/vapid_private.pem", vapid_claims={ "sub": "mailto:YourNameHere@example.org", } ) except WebPushException as ex: print("I'm sorry, Dave, but I can't do that: {}", repr(ex)) # Mozilla returns additional information in the body of the response. if ex.response is not None and ex.response.json(): extra = ex.response.json() print("Remote service replied with a {}:{}, {}", extra.code, extra.errno, extra.message ) Methods ~~~~~~~ If you expect to resend to the same recipient, or have more needs than just sending data quickly, you can pass just ``wp = WebPusher(subscription_info)``. This will return a ``WebPusher`` object. The following methods are available: ``.send(data, headers={}, ttl=0, gcm_key="", reg_id="", content_encoding="aes128gcm", curl=False, timeout=None)`` ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Send the data using additional parameters. On error, returns a ``WebPushException`` **Parameters** *data* Binary string of data to send *headers* A ``dict`` containing any additional headers to send *ttl* Message Time To Live on Push Server waiting for the client to reconnect (in seconds) *gcm_key* Google Cloud Messaging key (if using the older GCM push system) This is the API key obtained from the Google Developer Console. *reg_id* Google Cloud Messaging registration ID (will be extracted from endpoint if not specified) *content_encoding* ECE content encoding type (defaults to “aes128gcm”) *curl* Do not execute the POST, but return as a ``curl`` command. This will write the encrypted content to a local file named ``encrpypted.data``. This command is meant to be used for debugging purposes. *timeout* timeout for requests POST query. See `requests documentation `__. **Example** to send from Chrome using the old GCM mode: .. code:: python WebPusher(subscription_info).send(data, headers, ttl, gcm_key) ``.encode(data, content_encoding="aes128gcm")`` ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Encode the ``data`` for future use. On error, returns a ``WebPushException`` **Parameters** *data* Binary string of data to send *content_encoding* ECE content encoding type (defaults to “aes128gcm”) **Example** .. code:: python encoded_data = WebPush(subscription_info).encode(data) Stand Alone Webpush ------------------- If you’re not really into coding your own solution, there’s also a “stand-alone” ``pywebpush`` command in the ./bin directory. This uses two files: - the *data* file, which contains the message to send, in whatever form you like. - the *subscription info* file, which contains the subscription information as JSON encoded data. This is usually returned by the Push ``subscribe`` method and looks something like: .. code:: json {"endpoint": "https://push...", "keys": { "auth": "ab01...", "p256dh": "aa02..." }} If you’re interested in just testing your applications WebPush interface, you could use the Command Line: .. code:: bash ./bin/pywebpush --data stuff_to_send.data --info subscription.info which will encrypt and send the contents of ``stuff_to_send.data``. See ``./bin/pywebpush --help`` for available commands and options. .. |Build Status| image:: https://travis-ci.org/web-push-libs/pywebpush.svg?branch=main :target: https://travis-ci.org/web-push-libs/pywebpush .. |Requirements Status| image:: https://requires.io/github/web-push-libs/pywebpush/requirements.svg?branch=main :target: https://requires.io/github/web-push-libs/pywebpush/requirements/?branch=main ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1709572026.0 pywebpush-2.0.3/entry_points.txt0000664000175000017500000000007014571377672017340 0ustar00jrconlinjrconlin[console_scripts] pywebpush = "pywebpush.__main__:main" ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1547760350.0 pywebpush-2.0.3/local_test.txt0000664000175000017500000000015513420171336016715 0ustar00jrconlinjrconlinAmidst the mists and coldest frosts I thrust my fists against the posts and still demand to see the ghosts. ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1732051373.0 pywebpush-2.0.3/pyproject.toml0000664000175000017500000000247714717200655016757 0ustar00jrconlinjrconlin[build-system] # This uses the semi-built-in "setuptools" which is currently the # python pariah, but there are a lot of behaviors that still carry. # For more info see https://packaging.python.org/en/latest/ # (although, be fore-warned, it gets fairly wonky and obsessed with # details that you may not care about.) requires = ["setuptools"] build-backend = "setuptools.build_meta" [project] name = "pywebpush" version = "2.0.3" license = {text = "MPL-2.0"} authors = [{ name = "JR Conlin", email = "src+webpusher@jrconlin.com" }] description = "WebPush publication library" readme = "README.md" keywords = ["webpush", "vapid", "notification"] classifiers = [ "Topic :: Internet :: WWW/HTTP", "Programming Language :: Python :: Implementation :: PyPy", "Programming Language :: Python", "Programming Language :: Python :: 3", "License :: OSI Approved :: Mozilla Public License 2.0 (MPL 2.0)", ] dynamic = ["dependencies"] [project.urls] Homepage = "https://github.com/web-push-libs/pywebpush" [project.optional-dependencies] dev = ["black", "mock", "pytest"] # create the `pywebpush` helper using `python -m pip install --editable .` [project.scripts] pywebpush = "pywebpush.__main__:main" [tool.setuptools.dynamic] dependencies = {file = "requirements.txt"} [tool.setuptools.packages.find] include = ["pywebpush*"] ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1732051714.4928493 pywebpush-2.0.3/pywebpush/0000775000175000017500000000000014717201402016046 5ustar00jrconlinjrconlin././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1728920341.0 pywebpush-2.0.3/pywebpush/__init__.py0000664000175000017500000005206414703235425020175 0ustar00jrconlinjrconlin# This Source Code Form is subject to the terms of the Mozilla Public # License, v. 2.0. If a copy of the MPL was not distributed with this # file, You can obtain one at http://mozilla.org/MPL/2.0/. import asyncio import base64 import json import os import time import logging from copy import deepcopy from typing import cast, Union, Dict try: from urlparse import urlparse except ImportError: # pragma nocover from urllib.parse import urlparse import aiohttp import http_ece import requests import six from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives.asymmetric import ec from cryptography.hazmat.primitives import serialization from functools import partial from py_vapid import Vapid, Vapid01 from requests import Response class WebPushException(Exception): """Web Push failure. This may contain the requests.Response """ def __init__(self, message, response=None): self.message = message self.response = response def __str__(self): extra = "" if self.response is not None: try: extra = ", Response {}".format( self.response.text, ) except AttributeError: extra = ", Response {}".format(self.response) return "WebPushException: {}{}".format(self.message, extra) class NoData(Exception): """Message contained No Data, no encoding required.""" class CaseInsensitiveDict(dict): """A dictionary that has case-insensitive keys""" def __init__(self, data={}, **kwargs): for key in data: dict.__setitem__(self, key.lower(), data[key]) self.update(kwargs) def __contains__(self, key): return dict.__contains__(self, key.lower()) def __setitem__(self, key, value): dict.__setitem__(self, key.lower(), value) def __getitem__(self, key): return dict.__getitem__(self, key.lower()) def __delitem__(self, key): dict.__delitem__(self, key.lower()) def get(self, key, default=None): try: return self.__getitem__(key) except KeyError: return default def update(self, data): for key in data: self.__setitem__(key, data[key]) class WebPusher: """WebPusher encrypts a data block using HTTP Encrypted Content Encoding for WebPush. See https://tools.ietf.org/html/draft-ietf-webpush-protocol-04 for the current specification, and https://developer.mozilla.org/en-US/docs/Web/API/Push_API for an overview of Web Push. Example of use: The javascript promise handler for PushManager.subscribe() receives a subscription_info object. subscription_info.getJSON() will return a JSON representation. (e.g. .. code-block:: javascript subscription_info.getJSON() == {"endpoint": "https://push.server.com/...", "keys":{"auth": "...", "p256dh": "..."} } ) This subscription_info block can be stored. To send a subscription update: .. code-block:: python # Optional # headers = py_vapid.sign({"aud": "https://push.server.com/", "sub": "mailto:your_admin@your.site.com"}) data = "Mary had a little lamb, with a nice mint jelly" WebPusher(subscription_info).send(data, headers) """ subscription_info = {} valid_encodings = [ # "aesgcm128", # this is draft-0, but DO NOT USE. "aesgcm", # draft-httpbis-encryption-encoding-01 "aes128gcm", # RFC8188 Standard encoding ] verbose = False # Note: the type declarations are not valid under python 3.8, def __init__( self, subscription_info: Dict[ str, Union[Union[str, bytes], Dict[str, Union[str, bytes]]] ], requests_session: Union[None, requests.Session] = None, aiohttp_session: Union[None, aiohttp.client.ClientSession] = None, verbose: bool = False, ): """Initialize using the info provided by the client PushSubscription object (See https://developer.mozilla.org/en-US/docs/Web/API/PushManager/subscribe) :param subscription_info: a dict containing the subscription_info from the client. :type subscription_info: dict :param requests_session: a requests.Session object to optimize requests to the same client. :type requests_session: requests.Session :param verbose: provide verbose feedback :type verbose: bool """ self.verbose = verbose if requests_session is None: self.requests_method = requests else: self.requests_method = requests_session self.aiohttp_session = aiohttp_session if "endpoint" not in subscription_info: raise WebPushException("subscription_info missing endpoint URL") self.subscription_info = deepcopy(subscription_info) self.auth_key = self.receiver_key = None if "keys" in subscription_info: keys: Dict[str, Union[str, bytes]] = cast( Dict[str, Union[str, bytes]], self.subscription_info["keys"] ) for k in ["p256dh", "auth"]: if keys.get(k) is None: raise WebPushException("Missing keys value: {}".format(k)) if isinstance(keys[k], six.text_type): keys[k] = bytes(cast(str, keys[k]).encode("utf8")) receiver_raw = base64.urlsafe_b64decode( self._repad(cast(bytes, keys["p256dh"])) ) if len(receiver_raw) != 65 and receiver_raw[0] != "\x04": raise WebPushException("Invalid p256dh key specified") self.receiver_key = receiver_raw self.auth_key = base64.urlsafe_b64decode( self._repad(cast(bytes, keys["auth"])) ) def verb(self, msg: str, *args, **kwargs): if self.verbose: logging.info(msg.format(*args, **kwargs)) def _repad(self, data: bytes): """Add base64 padding to the end of a string, if required""" return data + b"===="[: len(data) % 4] def encode( self, data: bytes, content_encoding: str = "aes128gcm" ) -> CaseInsensitiveDict: """Encrypt the data. :param data: A serialized block of byte data (String, JSON, bit array, etc.) Make sure that whatever you send, your client knows how to understand it. :type data: str :param content_encoding: The content_encoding type to use to encrypt the data. Defaults to RFC8188 "aes128gcm". The previous draft-01 is "aesgcm", however this format is now deprecated. :type content_encoding: enum("aesgcm", "aes128gcm") """ reply = CaseInsensitiveDict() # Salt is a random 16 byte array. if not data: self.verb("No data found...") raise NoData() if not self.auth_key or not self.receiver_key: raise WebPushException("No keys specified in subscription info") self.verb("Encoding data...") salt = None if content_encoding not in self.valid_encodings: raise WebPushException( "Invalid content encoding specified. " "Select from " + json.dumps(self.valid_encodings) ) if content_encoding == "aesgcm": self.verb("Generating salt for aesgcm...") salt = os.urandom(16) logging.debug("Salt: {}".format(salt)) # The server key is an ephemeral ECDH key used only for this # transaction server_key = ec.generate_private_key(ec.SECP256R1(), default_backend()) crypto_key = server_key.public_key().public_bytes( encoding=serialization.Encoding.X962, format=serialization.PublicFormat.UncompressedPoint, ) if isinstance(data, six.text_type): data = bytes(data.encode("utf8")) if content_encoding == "aes128gcm": self.verb("Encrypting to aes128gcm...") encrypted = http_ece.encrypt( data, salt=salt, private_key=server_key, dh=self.receiver_key, auth_secret=self.auth_key, version=content_encoding, ) reply["body"] = encrypted else: self.verb("Encrypting to aesgcm...") crypto_key = base64.urlsafe_b64encode(crypto_key).strip(b"=") encrypted = http_ece.encrypt( data, salt=salt, private_key=server_key, keyid=crypto_key.decode(), dh=self.receiver_key, auth_secret=self.auth_key, version=content_encoding, ) reply["crypto_key"] = crypto_key reply["body"] = encrypted if salt: reply["salt"] = base64.urlsafe_b64encode(salt).strip(b"=") return reply def as_curl(self, endpoint: str, encoded_data: bytes, headers: Dict[str, str]): """Return the send as a curl command. Useful for debugging. This will write out the encoded data to a local file named `encrypted.data` :param endpoint: Push service endpoint URL :type endpoint: basestring :param encoded_data: byte array of encoded data :type encoded_data: bytearray :param headers: Additional headers for the send :type headers: dict :returns string """ header_list = [ '-H "{}: {}" \\ \n'.format(key.lower(), val) for key, val in headers.items() ] data = "" if encoded_data: with open("encrypted.data", "wb") as f: f.write(encoded_data) data = "--data-binary @encrypted.data" if "content-length" not in headers: self.verb("Generating content-length header...") header_list.append( '-H "content-length: {}" \\ \n'.format(len(encoded_data)) ) return """curl -vX POST {url} \\\n{headers}{data}""".format( url=endpoint, headers="".join(header_list), data=data ) def _prepare_send_data( self, data: Union[None, bytes] = None, headers: Union[None, Dict[str, str]] = None, ttl: int = 0, gcm_key: Union[None, str] = None, reg_id: Union[None, str] = None, content_encoding: str = "aes128gcm", curl: bool = False, ) -> dict: """Encode and send the data to the Push Service. :param data: A serialized block of data (see encode() ). :type data: str :param headers: A dictionary containing any additional HTTP headers. :type headers: dict :param ttl: The Time To Live in seconds for this message if the recipient is not online. (Defaults to "0", which discards the message immediately if the recipient is unavailable.) :type ttl: int :param gcm_key: API key obtained from the Google Developer Console. Needed if endpoint is https://android.googleapis.com/gcm/send :type gcm_key: string :param reg_id: registration id of the recipient. If not provided, it will be extracted from the endpoint. :type reg_id: str :param content_encoding: ECE content encoding (defaults to "aes128gcm") :type content_encoding: str :param curl: Display output as `curl` command instead of sending :type curl: bool """ # Encode the data. if headers is None: headers = dict() encoded = CaseInsensitiveDict() headers = CaseInsensitiveDict(headers) if data: encoded = self.encode(data, content_encoding) if "crypto_key" in encoded: # Append the p256dh to the end of any existing crypto-key crypto_key = headers.get("crypto-key", "") if crypto_key: # due to some confusion by a push service provider, we # should use ';' instead of ',' to append the headers. # see # https://github.com/webpush-wg/webpush-encryption/issues/6 crypto_key += ";" crypto_key += "dh=" + encoded["crypto_key"].decode("utf8") headers.update({"crypto-key": crypto_key}) if "salt" in encoded: headers.update({"encryption": "salt=" + encoded["salt"].decode("utf8")}) headers.update( { "content-encoding": content_encoding, } ) if gcm_key: # guess if it is a legacy GCM project key or actual FCM key # gcm keys are all about 40 chars (use 100 for confidence), # fcm keys are 153-175 chars if len(gcm_key) < 100: self.verb("Guessing this is legacy GCM...") endpoint = "https://android.googleapis.com/gcm/send" else: self.verb("Guessing this is FCM...") endpoint = "https://fcm.googleapis.com/fcm/send" reg_ids = [] if not reg_id: reg_id = cast(str, self.subscription_info["endpoint"]).rsplit("/", 1)[ -1 ] self.verb("Fetching out registration id: {}", reg_id) reg_ids.append(reg_id) gcm_data = dict() gcm_data["registration_ids"] = reg_ids if data: buffer = encoded.get("body") if buffer: gcm_data["raw_data"] = base64.b64encode(buffer).decode("utf8") gcm_data["time_to_live"] = int(headers["ttl"] if "ttl" in headers else ttl) encoded_data = json.dumps(gcm_data) headers.update( { "Authorization": "key=" + gcm_key, "Content-Type": "application/json", } ) else: encoded_data = encoded.get("body") endpoint = self.subscription_info["endpoint"] if "ttl" not in headers or ttl: self.verb("Generating TTL of 0...") headers["ttl"] = str(ttl or 0) # Additionally useful headers: # Authorization / Crypto-Key (VAPID headers) self.verb( "\nSending request to" "\n\thost: {}\n\theaders: {}\n\tdata: {}", endpoint, headers, encoded_data, ) return {"endpoint": endpoint, "data": encoded_data, "headers": headers} def send(self, *args, **kwargs) -> Union[Response, str]: """Encode and send the data to the Push Service""" timeout = kwargs.pop("timeout", 10000) curl = kwargs.pop("curl", False) params = self._prepare_send_data(*args, **kwargs) endpoint = params.pop("endpoint") if curl: encoded_data = params["data"] headers = params["headers"] return self.as_curl(endpoint, encoded_data=encoded_data, headers=headers) resp = self.requests_method.post( endpoint, timeout=timeout, **params, ) self.verb( "\nResponse:\n\tcode: {}\n\tbody: {}\n", resp.status_code, resp.text or "Empty", ) return resp async def send_async(self, *args, **kwargs) -> Union[aiohttp.ClientResponse, str]: timeout = kwargs.pop("timeout", 10000) curl = kwargs.pop("curl", False) params = self._prepare_send_data(*args, **kwargs) endpoint = params.pop("endpoint") if curl: encoded_data = params["data"] headers = params["headers"] return self.as_curl(endpoint, encoded_data=encoded_data, headers=headers) if self.aiohttp_session: resp = await self.aiohttp_session.post(endpoint, timeout=timeout, **params) resp_text = await resp.text() else: async with aiohttp.ClientSession() as session: resp = await session.post(endpoint, timeout=timeout, **params) resp_text = await resp.text() self.verb( "\nResponse:\n\tcode: {}\n\tbody: {}\n", resp.status, resp_text or "Empty", ) return resp def webpush( subscription_info: Dict[ str, Union[Union[str, bytes], Dict[str, Union[str, bytes]]] ], data: Union[None, str] = None, vapid_private_key: Union[None, Vapid, str] = None, vapid_claims: Union[None, Dict[str, Union[str, int]]] = None, content_encoding: str = "aes128gcm", curl: bool = False, timeout: Union[None, float] = None, ttl: int = 0, verbose: bool = False, headers: Union[None, Dict[str, Union[str, int, float]]] = None, requests_session: Union[None, requests.Session] = None, ) -> Union[str, requests.Response]: """ One call solution to endcode and send `data` to the endpoint contained in `subscription_info` using optional VAPID auth headers. in example: .. code-block:: python from pywebpush import python webpush( subscription_info={ "endpoint": "https://push.example.com/v1/abcd", "keys": {"p256dh": "0123abcd...", "auth": "001122..."} }, data="Mary had a little lamb, with a nice mint jelly", vapid_private_key="path/to/key.pem", vapid_claims={"sub": "YourNameHere@example.com"} ) No additional method call is required. Any non-success will throw a `WebPushException`. :param subscription_info: Provided by the client call :type subscription_info: dict :param data: Serialized data to send :type data: str :param vapid_private_key: Vapid instance or path to vapid private key PEM \ or encoded str :type vapid_private_key: Union[Vapid, str] :param vapid_claims: Dictionary of claims ('sub' required) :type vapid_claims: dict :param content_encoding: Optional content type string :type content_encoding: str :param curl: Return as "curl" string instead of sending :type curl: bool :param timeout: POST requests timeout :type timeout: float :param ttl: Time To Live :type ttl: int :param verbose: Provide verbose feedback :type verbose: bool :return requests.Response or string :param headers: Dictionary of extra HTTP headers to include :type headers: dict """ if headers is None: headers = dict() else: # Ensure we don't leak VAPID headers by mutating the passed in dict. headers = headers.copy() vapid_headers = None if vapid_claims: if verbose: logging.info("Generating VAPID headers...") if not vapid_claims.get("aud"): url = urlparse(cast(str, subscription_info.get("endpoint"))) aud = "{}://{}".format(url.scheme, url.netloc) vapid_claims["aud"] = aud # Remember, passed structures are mutable in python. # It's possible that a previously set `exp` field is no longer valid. if not vapid_claims.get("exp") or int(vapid_claims.get("exp") or 0) < int( time.time() ): # encryption lives for 12 hours vapid_claims["exp"] = int(time.time()) + (12 * 60 * 60) if verbose: logging.info("Setting VAPID expry to {}...".format(vapid_claims["exp"])) if not vapid_private_key: raise WebPushException("VAPID dict missing 'private_key'") if isinstance(vapid_private_key, Vapid01): if verbose: logging.info("Looks like we already have a valid VAPID key") vv = vapid_private_key elif os.path.isfile(vapid_private_key): # Presume that key from file is handled correctly by # py_vapid. if verbose: logging.info("Reading VAPID key from file {}".format(vapid_private_key)) vv = Vapid.from_file(private_key_file=vapid_private_key) # pragma no cover else: if verbose: logging.info("Reading VAPID key from arguments") vv = Vapid.from_string(private_key=vapid_private_key) if verbose: logging.info("\t claims: {}".format(vapid_claims)) vapid_headers = vv.sign(vapid_claims) if verbose: logging.info("\t headers: {}".format(vapid_headers)) headers.update(vapid_headers) response = WebPusher( subscription_info, requests_session=requests_session, verbose=verbose ).send( data, headers, ttl=ttl, content_encoding=content_encoding, curl=curl, timeout=timeout, ) if not curl and cast(Response, response).status_code > 202: response = cast(Response, response) raise WebPushException( "Push failed: {} {}\nResponse body:{}".format( response.status_code, response.reason, response.text ), response=response, ) return response ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1718396973.0 pywebpush-2.0.3/pywebpush/__main__.py0000664000175000017500000000546514633124055020157 0ustar00jrconlinjrconlinimport argparse import os import json import logging from requests import JSONDecodeError from pywebpush import webpush, WebPushException def get_config(): parser = argparse.ArgumentParser(description="WebPush tool") parser.add_argument("--data", "-d", help="Data file") parser.add_argument("--info", "-i", help="Subscription Info JSON file") parser.add_argument("--head", help="Header Info JSON file") parser.add_argument("--claims", help="Vapid claim file") parser.add_argument("--key", help="Vapid private key file path") parser.add_argument( "--curl", help="Don't send, display as curl command", default=False, action="store_true", ) parser.add_argument("--encoding", default="aes128gcm") parser.add_argument( "--verbose", "-v", help="Provide verbose feedback", default=False, action="store_true", ) args = parser.parse_args() if not args.info: raise WebPushException("Subscription Info argument missing.") if not os.path.exists(args.info): raise WebPushException("Subscription Info file missing.") try: with open(args.info) as r: try: args.sub_info = json.loads(r.read()) except JSONDecodeError as e: raise WebPushException( "Could not read the subscription info file: {}", e ) if args.data: with open(args.data) as r: args.data = r.read() if args.head: with open(args.head) as r: try: args.head = json.loads(r.read()) except JSONDecodeError as e: raise WebPushException("Could not read the header arguments: {}", e) if args.claims: if not args.key: raise WebPushException("No private --key specified for claims") with open(args.claims) as r: try: args.claims = json.loads(r.read()) except JSONDecodeError as e: raise WebPushException( "Could not read the VAPID claims file {}".format(e) ) except Exception as ex: logging.error("Couldn't read input {}.".format(ex)) raise ex return args def main(): """Send data""" try: args = get_config() result = webpush( args.sub_info, data=args.data, vapid_private_key=args.key, vapid_claims=args.claims, curl=args.curl, content_encoding=args.encoding, verbose=args.verbose, headers=args.head, ) print(result) except Exception as ex: logging.error("{}".format(ex)) if __name__ == "__main__": main() ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1732051714.4928493 pywebpush-2.0.3/pywebpush/tests/0000775000175000017500000000000014717201402017210 5ustar00jrconlinjrconlin././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1703783710.0 pywebpush-2.0.3/pywebpush/tests/__init__.py0000644000175000017500000000000014543326436021321 0ustar00jrconlinjrconlin././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1728920333.0 pywebpush-2.0.3/pywebpush/tests/test_webpush.py0000664000175000017500000004646614703235415022324 0ustar00jrconlinjrconlinimport base64 import json import os import unittest import time from typing import cast, Union, Dict import http_ece import py_vapid import requests from mock import patch, Mock, AsyncMock from cryptography.hazmat.primitives.asymmetric import ec from cryptography.hazmat.primitives import serialization from cryptography.hazmat.backends import default_backend from pywebpush import WebPusher, NoData, WebPushException, CaseInsensitiveDict, webpush class WebpushTestUtils(unittest.TestCase): # This is a exported DER formatted string of an ECDH public key # This was lifted from the py_vapid tests. vapid_key = ( "MHcCAQEEIPeN1iAipHbt8+/KZ2NIF8NeN24jqAmnMLFZEMocY8RboAoGCCqGSM49" "AwEHoUQDQgAEEJwJZq/GN8jJbo1GGpyU70hmP2hbWAUpQFKDByKB81yldJ9GTklB" "M5xqEwuPM7VuQcyiLDhvovthPIXx+gsQRQ==" ) def _gen_subscription_info(self, recv_key=None, endpoint="https://example.com/"): if not recv_key: recv_key = ec.generate_private_key(ec.SECP256R1(), default_backend()) return { "endpoint": endpoint, "keys": { "auth": base64.urlsafe_b64encode(os.urandom(16)).strip(b"="), "p256dh": self._get_pubkey_str(recv_key), }, } def _get_pubkey_str(self, priv_key): return base64.urlsafe_b64encode( priv_key.public_key().public_bytes( encoding=serialization.Encoding.X962, format=serialization.PublicFormat.UncompressedPoint, ) ).strip(b"=") def test_init(self): # use static values so we know what to look for in the reply subscription_info = { "endpoint": "https://example.com/", "keys": { "p256dh": ( "BOrnIslXrUow2VAzKCUAE4sIbK00daEZCswOcf8m3T" "F8V82B-OpOg5JbmYLg44kRcvQC1E2gMJshsUYA-_zMPR8" ), "auth": "k8JV6sjdbhAi1n3_LDBLvA", }, } rk_decode = ( b'\x04\xea\xe7"\xc9W\xadJ0\xd9P3(%\x00\x13\x8b' b"\x08l\xad4u\xa1\x19\n\xcc\x0eq\xff&\xdd1" b"|W\xcd\x81\xf8\xeaN\x83\x92[\x99\x82\xe0\xe3" b"\x89\x11r\xf4\x02\xd4M\xa00\x9b!\xb1F\x00" b"\xfb\xfc\xcc=\x1f" ) self.assertRaises( WebPushException, WebPusher, {"keys": {"p256dh": "AAA=", "auth": "AAA="}} ) self.assertRaises( WebPushException, WebPusher, {"endpoint": "https://example.com", "keys": {"p256dh": "AAA="}}, ) self.assertRaises( WebPushException, WebPusher, {"endpoint": "https://example.com", "keys": {"auth": "AAA="}}, ) self.assertRaises( WebPushException, WebPusher, { "endpoint": "https://example.com", "keys": {"p256dh": "AAA=", "auth": "AAA="}, }, ) push = WebPusher(subscription_info) assert push.subscription_info != subscription_info assert push.subscription_info["keys"] != subscription_info["keys"] assert push.subscription_info["endpoint"] == subscription_info["endpoint"] assert push.receiver_key == rk_decode assert push.auth_key == b'\x93\xc2U\xea\xc8\xddn\x10"\xd6}\xff,0K\xbc' def test_encode(self): for content_encoding in ["aesgcm", "aes128gcm"]: recv_key = ec.generate_private_key(ec.SECP256R1(), default_backend()) subscription_info = self._gen_subscription_info(recv_key) data = "Mary had a little lamb, with some nice mint jelly" push = WebPusher(subscription_info) encoded = push.encode(data.encode(), content_encoding=content_encoding) """ crypto_key = base64.urlsafe_b64encode( self._get_pubkey_str(recv_key) ).strip(b'=') """ # Convert these b64 strings into their raw, binary form. raw_salt = None if "salt" in encoded: raw_salt = base64.urlsafe_b64decode(push._repad(encoded["salt"])) raw_dh = None if content_encoding != "aes128gcm": raw_dh = base64.urlsafe_b64decode(push._repad(encoded["crypto_key"])) raw_auth = base64.urlsafe_b64decode( push._repad(subscription_info["keys"]["auth"]) ) decoded = http_ece.decrypt( encoded["body"], salt=raw_salt, dh=raw_dh, private_key=recv_key, auth_secret=raw_auth, version=content_encoding, ) assert decoded.decode("utf8") == data def test_bad_content_encoding(self): subscription_info = self._gen_subscription_info() data = "Mary had a little lamb, with some nice mint jelly" push = WebPusher(subscription_info) self.assertRaises( WebPushException, push.encode, data, content_encoding="aesgcm128" ) @patch("requests.post") def test_send(self, mock_post): subscription_info = self._gen_subscription_info() headers = {"Crypto-Key": "pre-existing", "Authentication": "bearer vapid"} data = "Mary had a little lamb" WebPusher(subscription_info).send(data, headers) assert subscription_info.get("endpoint") == mock_post.call_args[0][0] pheaders = mock_post.call_args[1].get("headers") assert pheaders.get("ttl") == "0" assert pheaders.get("AUTHENTICATION") == headers.get("Authentication") ckey = pheaders.get("crypto-key") assert "pre-existing" in ckey assert pheaders.get("content-encoding") == "aes128gcm" @patch("requests.post") def test_send_vapid(self, mock_post): mock_post.return_value.status_code = 200 subscription_info = self._gen_subscription_info() data = "Mary had a little lamb" webpush( subscription_info=subscription_info, data=data, vapid_private_key=self.vapid_key, vapid_claims={"sub": "mailto:ops@example.com"}, content_encoding="aesgcm", headers={"Test-Header": "test-value"}, ) assert subscription_info.get("endpoint") == mock_post.call_args[0][0] pheaders = mock_post.call_args[1].get("headers") assert pheaders.get("ttl") == "0" def repad(str): return str + "===="[: len(str) % 4] auth = json.loads( base64.urlsafe_b64decode( repad(pheaders["authorization"].split(".")[1]) ).decode("utf8") ) assert subscription_info.get("endpoint", "").startswith(auth["aud"]) assert "vapid" in pheaders.get("authorization") ckey = pheaders.get("crypto-key") assert "dh=" in ckey assert pheaders.get("content-encoding") == "aesgcm" assert pheaders.get("test-header") == "test-value" @patch.object(WebPusher, "send") @patch.object(py_vapid.Vapid, "sign") def test_webpush_vapid_instance(self, vapid_sign, pusher_send): pusher_send.return_value.status_code = 200 subscription_info = self._gen_subscription_info() data = "Mary had a little lamb" vapid_key = py_vapid.Vapid.from_string(self.vapid_key) claims: Dict[str, Union[str, int]] = dict( sub="mailto:ops@example.com", aud="https://example.com" ) webpush( subscription_info=subscription_info, data=data, vapid_private_key=vapid_key, vapid_claims=claims, ) vapid_sign.assert_called_once_with(claims) pusher_send.assert_called_once() @patch.object(WebPusher, "send") @patch.object(py_vapid.Vapid, "sign") def test_webpush_vapid_exp(self, vapid_sign, pusher_send): pusher_send.return_value.status_code = 200 subscription_info = self._gen_subscription_info() data = "Mary had a little lamb" vapid_key = py_vapid.Vapid.from_string(self.vapid_key) claims = dict( sub="mailto:ops@example.com", aud="https://example.com", exp=int(time.time() - 48600), ) webpush( subscription_info=subscription_info, data=data, vapid_private_key=vapid_key, vapid_claims=claims, ) vapid_sign.assert_called_once_with(claims) pusher_send.assert_called_once() assert int(claims["exp"]) > int(time.time()) @patch("requests.post") def test_send_bad_vapid_no_key(self, mock_post): mock_post.return_value.status_code = 200 subscription_info = self._gen_subscription_info() data = "Mary had a little lamb" self.assertRaises( WebPushException, webpush, subscription_info=subscription_info, data=data, vapid_claims={ "aud": "https://example.com", "sub": "mailto:ops@example.com", }, ) @patch("requests.post") def test_send_bad_vapid_bad_return(self, mock_post): mock_post.return_value.status_code = 410 subscription_info = self._gen_subscription_info() data = "Mary had a little lamb" self.assertRaises( WebPushException, webpush, subscription_info=subscription_info, data=data, vapid_claims={ "aud": "https://example.com", "sub": "mailto:ops@example.com", }, vapid_private_key=self.vapid_key, ) @patch("requests.post") def test_send_empty(self, mock_post): subscription_info = self._gen_subscription_info() headers = {"Crypto-Key": "pre-existing", "Authentication": "bearer vapid"} WebPusher(subscription_info).send("", headers) assert subscription_info.get("endpoint") == mock_post.call_args[0][0] pheaders = mock_post.call_args[1].get("headers") assert pheaders.get("ttl") == "0" assert "encryption" not in pheaders assert pheaders.get("AUTHENTICATION") == headers.get("Authentication") ckey = pheaders.get("crypto-key") assert "pre-existing" in ckey def test_encode_empty(self): subscription_info = self._gen_subscription_info() headers = {"Crypto-Key": "pre-existing", "Authentication": "bearer vapid"} pusher = WebPusher(subscription_info) self.assertRaises(NoData, pusher.encode, "", headers) def test_encode_no_crypto(self): subscription_info = self._gen_subscription_info() del subscription_info["keys"] headers = {"Crypto-Key": "pre-existing", "Authentication": "bearer vapid"} data = "Something" pusher = WebPusher(subscription_info) self.assertRaises(WebPushException, pusher.encode, data, headers) @patch("requests.post") def test_send_no_headers(self, mock_post): subscription_info = self._gen_subscription_info() data = "Mary had a little lamb" WebPusher(subscription_info).send(data) assert subscription_info.get("endpoint") == mock_post.call_args[0][0] pheaders = mock_post.call_args[1].get("headers") assert pheaders.get("ttl") == "0" assert pheaders.get("content-encoding") == "aes128gcm" @patch("pywebpush.open") def test_as_curl(self, opener): subscription_info = self._gen_subscription_info() result = webpush( subscription_info, data="Mary had a little lamb", vapid_claims={ "aud": "https://example.com", "sub": "mailto:ops@example.com", }, vapid_private_key=self.vapid_key, curl=True, ) result = cast(str, result) for s in [ "curl -vX POST https://example.com", '-H "content-encoding: aes128gcm"', '-H "authorization: vapid ', '-H "ttl: 0"', '-H "content-length:', ]: assert s in result, "missing: {}".format(s) def test_ci_dict(self): ci = CaseInsensitiveDict({"Foo": "apple", "bar": "banana"}) assert "apple" == ci["foo"] assert "apple" == ci.get("FOO") assert "apple" == ci.get("Foo") del ci["FOO"] assert ci.get("Foo") is None @patch("requests.post") def test_gcm(self, mock_post): subscription_info = self._gen_subscription_info( None, endpoint="https://android.googleapis.com/gcm/send/regid123" ) headers = {"Crypto-Key": "pre-existing", "Authentication": "bearer vapid"} data = "Mary had a little lamb" wp = WebPusher(subscription_info) wp.send(data, headers, gcm_key="gcm_key_value") pdata = json.loads(mock_post.call_args[1].get("data")) pheaders = mock_post.call_args[1].get("headers") assert pdata["registration_ids"][0] == "regid123" assert pheaders.get("authorization") == "key=gcm_key_value" assert pheaders.get("content-type") == "application/json" @patch("requests.post") def test_timeout(self, mock_post): mock_post.return_value.status_code = 200 subscription_info = self._gen_subscription_info() WebPusher(subscription_info).send(timeout=5.2) assert mock_post.call_args[1].get("timeout") == 5.2 webpush(subscription_info, timeout=10.001) assert mock_post.call_args[1].get("timeout") == 10.001 @patch("requests.Session") def test_send_using_requests_session(self, mock_session): subscription_info = self._gen_subscription_info() headers = {"Crypto-Key": "pre-existing", "Authentication": "bearer vapid"} data = "Mary had a little lamb" WebPusher(subscription_info, requests_session=mock_session).send(data, headers) assert subscription_info.get("endpoint") == mock_session.post.call_args[0][0] pheaders = mock_session.post.call_args[1].get("headers") assert pheaders.get("ttl") == "0" assert pheaders.get("AUTHENTICATION") == headers.get("Authentication") ckey = pheaders.get("crypto-key") assert "pre-existing" in ckey assert pheaders.get("content-encoding") == "aes128gcm" class WebPusherAsyncTestCase(WebpushTestUtils, unittest.IsolatedAsyncioTestCase): @patch("aiohttp.ClientSession.post", new_callable=AsyncMock) async def test_send(self, mock_post): subscription_info = self._gen_subscription_info() headers = {"Crypto-Key": "pre-existing", "Authentication": "bearer vapid"} data = "Mary had a little lamb" await WebPusher(subscription_info).send_async(data, headers) assert subscription_info.get("endpoint") == mock_post.call_args[0][0] pheaders = mock_post.call_args[1].get("headers") assert pheaders.get("ttl") == "0" assert pheaders.get("AUTHENTICATION") == headers.get("Authentication") ckey = pheaders.get("crypto-key") assert "pre-existing" in ckey assert pheaders.get("content-encoding") == "aes128gcm" @patch("aiohttp.ClientSession.post", new_callable=AsyncMock) async def test_send_empty(self, mock_post): subscription_info = self._gen_subscription_info() headers = {"Crypto-Key": "pre-existing", "Authentication": "bearer vapid"} await WebPusher(subscription_info).send_async("", headers) assert subscription_info.get("endpoint") == mock_post.call_args[0][0] pheaders = mock_post.call_args[1].get("headers") assert pheaders.get("ttl") == "0" assert "encryption" not in pheaders assert pheaders.get("AUTHENTICATION") == headers.get("Authentication") ckey = pheaders.get("crypto-key") assert "pre-existing" in ckey @patch("aiohttp.ClientSession.post", new_callable=AsyncMock) async def test_send_no_headers(self, mock_post): subscription_info = self._gen_subscription_info() data = "Mary had a little lamb" await WebPusher(subscription_info).send_async(data) assert subscription_info.get("endpoint") == mock_post.call_args[0][0] pheaders = mock_post.call_args[1].get("headers") assert pheaders.get("ttl") == "0" assert pheaders.get("content-encoding") == "aes128gcm" @patch("aiohttp.ClientSession.post", new_callable=AsyncMock) async def test_fcm(self, mock_post): subscription_info = self._gen_subscription_info( None, endpoint="https://android.googleapis.com/fcm/send/regid123" ) headers = {"Crypto-Key": "pre-existing", "Authentication": "bearer vapid"} data = "Mary had a little lamb" wp = WebPusher(subscription_info) await wp.send_async(data, headers, gcm_key="gcm_key_value") pdata = json.loads(mock_post.call_args[1].get("data")) pheaders = mock_post.call_args[1].get("headers") assert pdata["registration_ids"][0] == "regid123" assert pheaders.get("authorization") == "key=gcm_key_value" assert pheaders.get("content-type") == "application/json" @patch("aiohttp.ClientSession.post", new_callable=AsyncMock) async def test_timeout(self, mock_post): mock_post.return_value.status_code = 200 subscription_info = self._gen_subscription_info() await WebPusher(subscription_info).send_async(timeout=5.2) assert mock_post.call_args[1].get("timeout") == 5.2 @patch("aiohttp.ClientSession", new_callable=AsyncMock) async def test_send_using_requests_session(self, mock_session): subscription_info = self._gen_subscription_info() headers = {"Crypto-Key": "pre-existing", "Authentication": "bearer vapid"} data = "Mary had a little lamb" await WebPusher(subscription_info, aiohttp_session=mock_session).send_async( data, headers ) assert subscription_info.get("endpoint") == mock_session.post.call_args[0][0] pheaders = mock_session.post.call_args[1].get("headers") assert pheaders.get("ttl") == "0" assert pheaders.get("AUTHENTICATION") == headers.get("Authentication") ckey = pheaders.get("crypto-key") assert "pre-existing" in ckey assert pheaders.get("content-encoding") == "aes128gcm" class WebpushExceptionTestCase(unittest.TestCase): def test_exception(self): from requests import Response exp = WebPushException("foo") assert "{}".format(exp) == "WebPushException: foo" # Really should try to load the response to verify, but this mock # covers what we need. response = Mock(spec=Response) response.text = ( '{"code": 401, "errno": 109, "error": ' '"Unauthorized", "more_info": "http://' "autopush.readthedocs.io/en/latest/htt" 'p.html#error-codes", "message": "Requ' "est did not validate missing authoriz" 'ation header"}' ) response.json.return_value = json.loads(response.text) response.status_code = 401 response.reason = "Unauthorized" exp = WebPushException("foo", response) assert "{}".format(exp) == "WebPushException: foo, Response {}".format( response.text ) assert "{}".format(exp.response), "" assert cast(requests.Response, exp.response).json().get("errno") == 109 exp = WebPushException("foo", [1, 2, 3]) assert "{}".format(exp) == "WebPushException: foo, Response [1, 2, 3]" ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1732051714.4928493 pywebpush-2.0.3/pywebpush.egg-info/0000775000175000017500000000000014717201402017540 5ustar00jrconlinjrconlin././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1732051714.0 pywebpush-2.0.3/pywebpush.egg-info/PKG-INFO0000644000175000017500000002017514717201402020640 0ustar00jrconlinjrconlinMetadata-Version: 2.1 Name: pywebpush Version: 2.0.3 Summary: WebPush publication library Author-email: JR Conlin License: MPL-2.0 Project-URL: Homepage, https://github.com/web-push-libs/pywebpush Keywords: webpush,vapid,notification Classifier: Topic :: Internet :: WWW/HTTP Classifier: Programming Language :: Python :: Implementation :: PyPy Classifier: Programming Language :: Python Classifier: Programming Language :: Python :: 3 Classifier: License :: OSI Approved :: Mozilla Public License 2.0 (MPL 2.0) Description-Content-Type: text/markdown License-File: LICENSE Requires-Dist: aiohttp Requires-Dist: cryptography>=2.6.1 Requires-Dist: http-ece>=1.1.0 Requires-Dist: requests>=2.21.0 Requires-Dist: six>=1.15.0 Requires-Dist: py-vapid>=1.7.0 Provides-Extra: dev Requires-Dist: black; extra == "dev" Requires-Dist: mock; extra == "dev" Requires-Dist: pytest; extra == "dev" # Webpush Data encryption library for Python [![Build Status](https://travis-ci.org/web-push-libs/pywebpush.svg?branch=main)](https://travis-ci.org/web-push-libs/pywebpush) [![Requirements Status](https://requires.io/github/web-push-libs/pywebpush/requirements.svg?branch=main)](https://requires.io/github/web-push-libs/pywebpush/requirements/?branch=main) This library is available on [pypi as pywebpush](https://pypi.python.org/pypi/pywebpush). Source is available on [github](https://github.com/mozilla-services/pywebpush). Please note: This library was designated as a `Critical Project` by PyPi, it is currently maintained by [a single person](https://xkcd.com/2347/). I still accept PRs and Issues, but make of that what you will. ## Installation To work with this repo locally, you'll need to run `python -m venv venv`. Then `venv/bin/pip install --editable .` ## Usage In the browser, the promise handler for [registration.pushManager.subscribe()](https://developer.mozilla.org/en-US/docs/Web/API/PushManager/subscribe) returns a [PushSubscription](https://developer.mozilla.org/en-US/docs/Web/API/PushSubscription) object. This object has a .toJSON() method that will return a JSON object that contains all the info we need to encrypt and push data. As illustration, a `subscription_info` object may look like: ```json { "endpoint": "https://updates.push.services.mozilla.com/push/v1/gAA...", "keys": { "auth": "k8J...", "p256dh": "BOr..." } } ``` How you send the PushSubscription data to your backend, store it referenced to the user who requested it, and recall it when there's a new push subscription update is left as an exercise for the reader. ### Sending Data using `webpush()` One Call In many cases, your code will be sending a single message to many recipients. There's a "One Call" function which will make things easier. ```python from pywebpush import webpush webpush(subscription_info, data, vapid_private_key="Private Key or File Path[1]", vapid_claims={"sub": "mailto:YourEmailAddress"}) ``` This will encode `data`, add the appropriate VAPID auth headers if required and send it to the push server identified in the `subscription_info` block. ##### Parameters _subscription_info_ - The `dict` of the subscription info (described above). _data_ - can be any serial content (string, bit array, serialized JSON, etc), but be sure that your receiving application is able to parse and understand it. (e.g. `data = "Mary had a little lamb."`) _content_type_ - specifies the form of Encryption to use, either `'aes128gcm'` or the deprecated `'aesgcm'`. NOTE that not all User Agents can decrypt `'aesgcm'`, so the library defaults to the RFC 8188 standard form. _vapid_claims_ - a `dict` containing the VAPID claims required for authorization (See [py_vapid](https://github.com/web-push-libs/vapid/tree/master/python) for more details). If `aud` is not specified, pywebpush will attempt to auto-fill from the `endpoint`. If `exp` is not specified or set in the past, it will be set to 12 hours from now. In both cases, the passed `dict` **will be mutated** after the call. _vapid_private_key_ - Either a path to a VAPID EC2 private key PEM file, or a string containing the DER representation. (See [py_vapid](https://github.com/web-push-libs/vapid/tree/master/python) for more details.) The `private_key` may be a base64 encoded DER formatted private key, or the path to an OpenSSL exported private key file. e.g. the output of: ```bash openssl ecparam -name prime256v1 -genkey -noout -out private_key.pem ``` ##### Example ```python from pywebpush import webpush, WebPushException try: webpush( subscription_info={ "endpoint": "https://push.example.com/v1/12345", "keys": { "p256dh": "0123abcde...", "auth": "abc123..." }}, data="Mary had a little lamb, with a nice mint jelly", vapid_private_key="path/to/vapid_private.pem", vapid_claims={ "sub": "mailto:YourNameHere@example.org", } ) except WebPushException as ex: print("I'm sorry, Dave, but I can't do that: {}", repr(ex)) # Mozilla returns additional information in the body of the response. if ex.response is not None and ex.response.json(): extra = ex.response.json() print("Remote service replied with a {}:{}, {}", extra.code, extra.errno, extra.message ) ``` ### Methods If you expect to resend to the same recipient, or have more needs than just sending data quickly, you can pass just `wp = WebPusher(subscription_info)`. This will return a `WebPusher` object. The following methods are available: #### `.send(data, headers={}, ttl=0, gcm_key="", reg_id="", content_encoding="aes128gcm", curl=False, timeout=None)` Send the data using additional parameters. On error, returns a `WebPushException` ##### Parameters _data_ Binary string of data to send _headers_ A `dict` containing any additional headers to send _ttl_ Message Time To Live on Push Server waiting for the client to reconnect (in seconds) _gcm_key_ Google Cloud Messaging key (if using the older GCM push system) This is the API key obtained from the Google Developer Console. _reg_id_ Google Cloud Messaging registration ID (will be extracted from endpoint if not specified) _content_encoding_ ECE content encoding type (defaults to "aes128gcm") _curl_ Do not execute the POST, but return as a `curl` command. This will write the encrypted content to a local file named `encrpypted.data`. This command is meant to be used for debugging purposes. _timeout_ timeout for requests POST query. See [requests documentation](http://docs.python-requests.org/en/master/user/quickstart/#timeouts). ##### Example to send from Chrome using the old GCM mode: ```python WebPusher(subscription_info).send(data, headers, ttl, gcm_key) ``` #### `.encode(data, content_encoding="aes128gcm")` Encode the `data` for future use. On error, returns a `WebPushException` ##### Parameters _data_ Binary string of data to send _content_encoding_ ECE content encoding type (defaults to "aes128gcm") *Note* This will return a `NoData` exception if the data is not present or empty. It is completely valid to send a WebPush notification with no data, but encoding is a no-op in that case. Best not to call it if you don't have data. ##### Example ```python encoded_data = WebPush(subscription_info).encode(data) ``` ## Stand Alone Webpush If you're not really into coding your own solution, there's also a "stand-alone" `pywebpush` command in the ./bin directory. This uses two files: - the _data_ file, which contains the message to send, in whatever form you like. - the _subscription info_ file, which contains the subscription information as JSON encoded data. This is usually returned by the Push `subscribe` method and looks something like: ```json { "endpoint": "https://push...", "keys": { "auth": "ab01...", "p256dh": "aa02..." } } ``` If you're interested in just testing your applications WebPush interface, you could use the Command Line: ```bash ./bin/pywebpush --data stuff_to_send.data --info subscription.info ``` which will encrypt and send the contents of `stuff_to_send.data`. See `./bin/pywebpush --help` for available commands and options. ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1732051714.0 pywebpush-2.0.3/pywebpush.egg-info/SOURCES.txt0000664000175000017500000000076114717201402021430 0ustar00jrconlinjrconlinCHANGELOG.md CODE_OF_CONDUCT.md LICENSE MANIFEST.in PULL_REQUEST_TEMPLATE.md README.md README.rst entry_points.txt local_test.txt pyproject.toml requirements.txt setup.cfg test-requirements.txt pywebpush/__init__.py pywebpush/__main__.py pywebpush.egg-info/PKG-INFO pywebpush.egg-info/SOURCES.txt pywebpush.egg-info/dependency_links.txt pywebpush.egg-info/entry_points.txt pywebpush.egg-info/requires.txt pywebpush.egg-info/top_level.txt pywebpush/tests/__init__.py pywebpush/tests/test_webpush.py././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1732051714.0 pywebpush-2.0.3/pywebpush.egg-info/dependency_links.txt0000664000175000017500000000000114717201402023606 0ustar00jrconlinjrconlin ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1732051714.0 pywebpush-2.0.3/pywebpush.egg-info/entry_points.txt0000664000175000017500000000006614717201402023040 0ustar00jrconlinjrconlin[console_scripts] pywebpush = pywebpush.__main__:main ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1732051714.0 pywebpush-2.0.3/pywebpush.egg-info/requires.txt0000664000175000017500000000016214717201402022137 0ustar00jrconlinjrconlinaiohttp cryptography>=2.6.1 http-ece>=1.1.0 requests>=2.21.0 six>=1.15.0 py-vapid>=1.7.0 [dev] black mock pytest ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1732051714.0 pywebpush-2.0.3/pywebpush.egg-info/top_level.txt0000664000175000017500000000001214717201402022263 0ustar00jrconlinjrconlinpywebpush ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1723825530.0 pywebpush-2.0.3/requirements.txt0000664000175000017500000000013114657676572017332 0ustar00jrconlinjrconlinaiohttp cryptography>=2.6.1 http-ece>=1.1.0 requests>=2.21.0 six>=1.15.0 py-vapid>=1.7.0 ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1732051714.4938493 pywebpush-2.0.3/setup.cfg0000664000175000017500000000027414717201402015644 0ustar00jrconlinjrconlin[nosetests] verbose = True verbosity = 1 cover-tests = True cover-erase = True with-coverage = True detailed-errors = True cover-package = pywebpush [egg_info] tag_build = tag_date = 0 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1709572026.0 pywebpush-2.0.3/test-requirements.txt0000664000175000017500000000004514571377672020305 0ustar00jrconlinjrconlin-r requirements.txt black mock pytest