Merge pull request 'feat/push-notifications' (#34) from BordedDev/snek:feat/push-notifications into main
Reviewed-on: retoor/snek#34
This commit is contained in:
		
						commit
						831b5c17cd
					
				
							
								
								
									
										21
									
								
								cert.pem
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								cert.pem
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,21 @@ | ||||
| -----BEGIN CERTIFICATE----- | ||||
| MIIDazCCAlOgAwIBAgIUB7PQvHZD6v8hfxeaDbU3hC0nGQQwDQYJKoZIhvcNAQEL | ||||
| BQAwRTELMAkGA1UEBhMCTkwxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoM | ||||
| GEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDAeFw0yNTA0MDYxOTUzMDhaFw0yNjA0 | ||||
| MDYxOTUzMDhaMEUxCzAJBgNVBAYTAk5MMRMwEQYDVQQIDApTb21lLVN0YXRlMSEw | ||||
| HwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwggEiMA0GCSqGSIb3DQEB | ||||
| AQUAA4IBDwAwggEKAoIBAQCtYf8PP7QjRJOfK6zmfAZhSKwMowCSYijKeChxsgyn | ||||
| hDDE8A/OuOuluJh6M/X+ZH0Q4HWTAaTwrXesBBPhie+4KmtsykiI7QEHXVVrWHba | ||||
| 6t5ymKiFiu+rWMwJVznS7T8K+DPGLRO2bF71Fme4ofJ2Plb7PnF53R4Tc3aTMdIW | ||||
| HrUsU1JMNmbCibSVlkfPXSg/HY3XLysCrtrldPHYbTGvBcDUil7qZ8hZ8ZxLMzu3 | ||||
| GPo6awPc0RBqw3tZu6SCECwQJEM0gX2n5nSyVz+fVgvLozNL9kV89hbZo7H/M37O | ||||
| zmxVNwsAwoHpAGmnYs3ZYt4Q8duYjF1AtgZyXgXgdMdLAgMBAAGjUzBRMB0GA1Ud | ||||
| DgQWBBQtGeiVTYjzWb2hTqJwipRVXU1LnzAfBgNVHSMEGDAWgBQtGeiVTYjzWb2h | ||||
| TqJwipRVXU1LnzAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQAc | ||||
| 1BacrGMlCd5nfYuvQfv0DdTVGc2FSqxPMRGrZKfjvjemgPMs0+DqUwCJiR6oEOGb | ||||
| atOYoIBX9KGXSUKRYYc/N75bslwfV1CclNqd2mPxULfks/D8cAzf2mgw4kYSaDHs | ||||
| tJkywBe9L6eIK4cQ5YJvutVNVKMYPi+9w+wKog/FafkamFfX/3SLCkGmV0Vv4g0q | ||||
| Ro9KmTTQpJUvd63X8bONLs1t8p+HQfWmKlhuVn5+mncNdGREe8dbciXE5FKu8luN | ||||
| dr/twoTZTPhmIHPmVEeNxS8hFSiu0iUPTO0HcCAODILGbtXbClA+1Z0ukiRfUya6 | ||||
| tgVuEk0c64L86qGP7Ply | ||||
| -----END CERTIFICATE----- | ||||
							
								
								
									
										28
									
								
								key.pem
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								key.pem
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,28 @@ | ||||
| -----BEGIN PRIVATE KEY----- | ||||
| MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQCtYf8PP7QjRJOf | ||||
| K6zmfAZhSKwMowCSYijKeChxsgynhDDE8A/OuOuluJh6M/X+ZH0Q4HWTAaTwrXes | ||||
| BBPhie+4KmtsykiI7QEHXVVrWHba6t5ymKiFiu+rWMwJVznS7T8K+DPGLRO2bF71 | ||||
| Fme4ofJ2Plb7PnF53R4Tc3aTMdIWHrUsU1JMNmbCibSVlkfPXSg/HY3XLysCrtrl | ||||
| dPHYbTGvBcDUil7qZ8hZ8ZxLMzu3GPo6awPc0RBqw3tZu6SCECwQJEM0gX2n5nSy | ||||
| Vz+fVgvLozNL9kV89hbZo7H/M37OzmxVNwsAwoHpAGmnYs3ZYt4Q8duYjF1AtgZy | ||||
| XgXgdMdLAgMBAAECggEAFnbkqz8fweoNY8mEOiDGWth695rZuh20bKIA63+cRXV1 | ||||
| NC8T0pRXGT5qUyW5sQpSwgWzINGiY09hJWJ/M5vBpDpVd4pbYj0DAxyZXV01mSER | ||||
| TVvGNKH5x65WUWeB0Hh40J0JaEXy5edIrmIGx6oEAO9hfxAUzStUeES05QFxgk1Q | ||||
| RI4rKgvVt4W4wEGqSqX7OMwSU1EHJkX+IKYUdXvFA4Gi192mHHhX9MMDK/RSaDOC | ||||
| 1ZzHzHeKoTlf4jaUcwATlibo8ExGu4wsY+y3+NKE15o6D36AZD7ObqDOF1RsyfGG | ||||
| eyljXzcglZAJN9Ctrz0xj5Xt22HqwsPO0o0mJ7URYQKBgQDcUWiu2acJJyjEJ89F | ||||
| aiw3z5RvyO9LksHXwkf6gAV+dro/JeUf7u9Qgz3bwnoqwL16u+vjZxrtcpzkjc2C | ||||
| +DIr6spCf8XkneJ2FovrFDe6oJSFxbgeexkQEBgw0TskRKILN8PGS6FAOfe8Zkwz | ||||
| OHAJOYjxoVVoSeDPnxdu6uwJSQKBgQDJdpwZrtjGKSxkcMJzUlmp3XAPdlI1hZkl | ||||
| v56Sdj6+Wz9bNTFlgiPHS+4Z7M+LyotShOEqwMfe+MDqVxTIB9TWfnmvnFDxI1VB | ||||
| orHogWVWMHOqPJAzGrrWgbG2CSIiwQ3WFxU1nXqAeNk9aIFidGco87l3lVb4XEZs | ||||
| eoUOUic/8wKBgQCK6r3x+gULjWhz/pH/t8l3y2hR78WKxld5XuQZvB06t0wKQy+s | ||||
| qfC1uHsJlR+I04zl1ZYQBdQBwlHQ/uSFX0/rRxkPQxeZZkADq4W/zTiycUwU6S2F | ||||
| 8qJD8ZH/Pf5niOsP3bKQ1uEu6R4e6fXEGiLyfheuG8cJggPBhhO1eWUpGQKBgQDC | ||||
| L+OzFce46gLyJYYopl3qz5iuLrx6/nVp31O3lOZRkZ52CcW9ND3MYjH1Jz++XNMC | ||||
| DTcEgKGnGFrLBnjvfiz3Ox2L2b5jUE1jYLDfjanh8/3pP0s3FzK0hHqJHjCbEz6E | ||||
| 9+bnsQ1dPB8Zg9wCzHSLErHYxEf6SOdQtJ//98wBZQKBgQDLON5QPUAJ21uZRvwv | ||||
| 9LsjKMpd5f/L6/q5j6YYXNpys5MREUgryDpR/uqcmyBuxCU3vBeK8tpYJzfXqO45 | ||||
| 5jFoiKhtEFXjb1+d18ACKg1gXQF0Ljry59HGiZOw7IubRPHh9CDdT5tzynylipr3 | ||||
| xhhX7RsDOYMFKmn59DS1CQCZAA== | ||||
| -----END PRIVATE KEY----- | ||||
| @ -1,12 +1,12 @@ | ||||
| import asyncio | ||||
| import logging | ||||
| import pathlib | ||||
| import time | ||||
| import ssl | ||||
| import uuid | ||||
| from datetime import datetime | ||||
| from snek import snode | ||||
| from snek.view.threads import ThreadsView | ||||
| import json  | ||||
| 
 | ||||
| logging.basicConfig(level=logging.DEBUG) | ||||
| from ipaddress import ip_address | ||||
| from concurrent.futures import ThreadPoolExecutor | ||||
| @ -22,6 +22,7 @@ from app.app import Application as BaseApplication | ||||
| from jinja2 import FileSystemLoader | ||||
| import IP2Location | ||||
| from snek.sssh import start_ssh_server | ||||
| 
 | ||||
| from snek.docs.app import Application as DocsApplication | ||||
| from snek.mapper import get_mappers | ||||
| from snek.service import get_services | ||||
| @ -38,6 +39,7 @@ from snek.view.drive import DriveView | ||||
| from snek.view.drive import DriveApiView | ||||
| from snek.view.index import IndexView | ||||
| from snek.view.login import LoginView | ||||
| from snek.view.push import PushView | ||||
| from snek.view.logout import LogoutView | ||||
| from snek.view.register import RegisterView | ||||
| from snek.view.rpc import RPCView | ||||
| @ -57,10 +59,16 @@ from snek.view.user import UserView | ||||
| from snek.view.web import WebView | ||||
| from snek.view.channel import ChannelAttachmentView | ||||
| from snek.view.channel import ChannelView | ||||
| from snek.view.settings.containers import ContainersIndexView, ContainersCreateView, ContainersUpdateView, ContainersDeleteView | ||||
| from snek.view.settings.containers import ( | ||||
|     ContainersIndexView, | ||||
|     ContainersCreateView, | ||||
|     ContainersUpdateView, | ||||
|     ContainersDeleteView, | ||||
| ) | ||||
| from snek.webdav import WebdavApplication | ||||
| from snek.system.template import sanitize_html | ||||
| from snek.sgit import GitApplication | ||||
| 
 | ||||
| SESSION_KEY = b"c79a0c5fda4b424189c427d28c9f7c34" | ||||
| from snek.system.template import whitelist_attributes | ||||
| 
 | ||||
| @ -71,6 +79,7 @@ async def session_middleware(request, handler): | ||||
|     response = await handler(request) | ||||
|     return response | ||||
| 
 | ||||
| 
 | ||||
| @web.middleware | ||||
| async def ip2location_middleware(request, handler): | ||||
|     response = await handler(request) | ||||
| @ -85,18 +94,19 @@ async def ip2location_middleware(request, handler): | ||||
|     if not user: | ||||
|         return response | ||||
|     location = request.app.ip2location.get(ip) | ||||
|     original_city = user['city'] | ||||
|     if user['city'] != location.city: | ||||
|         user['country_long'] = location.country | ||||
|         user['country_short'] = locaion.country_short | ||||
|         user['city'] = location.city | ||||
|         user['region'] = location.region | ||||
|         user['latitude'] = location.latitude | ||||
|         user['longitude'] = location.longitude | ||||
|         user['ip'] = ip | ||||
|     original_city = user["city"] | ||||
|     if user["city"] != location.city: | ||||
|         user["country_long"] = location.country | ||||
|         user["country_short"] = locaion.country_short | ||||
|         user["city"] = location.city | ||||
|         user["region"] = location.region | ||||
|         user["latitude"] = location.latitude | ||||
|         user["longitude"] = location.longitude | ||||
|         user["ip"] = ip | ||||
|         await request.app.services.user.update(user) | ||||
|     return response | ||||
| 
 | ||||
| 
 | ||||
| @web.middleware | ||||
| async def trailing_slash_middleware(request, handler): | ||||
|     if request.path and not request.path.endswith("/"): | ||||
| @ -106,7 +116,6 @@ async def trailing_slash_middleware(request, handler): | ||||
| 
 | ||||
| 
 | ||||
| class Application(BaseApplication): | ||||
| 
 | ||||
|     def __init__(self, *args, **kwargs): | ||||
|         middlewares = [ | ||||
|             cors_middleware, | ||||
| @ -117,7 +126,10 @@ class Application(BaseApplication): | ||||
|         self.template_path = pathlib.Path(__file__).parent.joinpath("templates") | ||||
|         self.static_path = pathlib.Path(__file__).parent.joinpath("static") | ||||
|         super().__init__( | ||||
|             middlewares=middlewares, template_path=self.template_path, client_max_size=1024*1024*1024*5 *args, **kwargs | ||||
|             middlewares=middlewares, | ||||
|             template_path=self.template_path, | ||||
|             client_max_size=1024 * 1024 * 1024 * 5 * args, | ||||
|             **kwargs, | ||||
|         ) | ||||
|         session_setup(self, EncryptedCookieStorage(SESSION_KEY)) | ||||
|         self.tasks = asyncio.Queue() | ||||
| @ -131,6 +143,7 @@ class Application(BaseApplication): | ||||
|         self.time_start = datetime.now() | ||||
|         self.ssh_host = "0.0.0.0" | ||||
|         self.ssh_port = 2242 | ||||
| 
 | ||||
|         self.setup_router() | ||||
|         self.ssh_server = None | ||||
|         self.sync_service = None | ||||
| @ -140,16 +153,15 @@ class Application(BaseApplication): | ||||
|         self.mappers = get_mappers(app=self) | ||||
|         self.broadcast_service = None | ||||
|         self.user_availability_service_task = None | ||||
|          | ||||
|         base_path = pathlib.Path(__file__).parent | ||||
|         self.ip2location = IP2Location.IP2Location(base_path.joinpath("IP2LOCATION-LITE-DB11.BIN")) | ||||
| 
 | ||||
|         self.ip2location = IP2Location.IP2Location( | ||||
|             base_path.joinpath("IP2LOCATION-LITE-DB11.BIN") | ||||
|         ) | ||||
|         self.on_startup.append(self.prepare_asyncio) | ||||
|         self.on_startup.append(self.start_user_availability_service) | ||||
|         self.on_startup.append(self.start_ssh_server) | ||||
|         self.on_startup.append(self.prepare_database) | ||||
| 
 | ||||
| 
 | ||||
|     @property | ||||
|     def uptime_seconds(self): | ||||
|         return (datetime.now() - self.time_start).total_seconds() | ||||
| @ -158,7 +170,7 @@ class Application(BaseApplication): | ||||
|     def uptime(self): | ||||
|         return self._format_uptime(self.uptime_seconds) | ||||
| 
 | ||||
|     def _format_uptime(self,seconds): | ||||
|     def _format_uptime(self, seconds): | ||||
|         seconds = int(seconds) | ||||
|         days, seconds = divmod(seconds, 86400) | ||||
|         hours, seconds = divmod(seconds, 3600) | ||||
| @ -176,14 +188,16 @@ class Application(BaseApplication): | ||||
| 
 | ||||
|         return ", ".join(parts) | ||||
| 
 | ||||
| 
 | ||||
|     async def start_user_availability_service(self, app): | ||||
|         app.user_availability_service_task = asyncio.create_task(app.services.socket.user_availability_service()) | ||||
|         app.user_availability_service_task = asyncio.create_task( | ||||
|             app.services.socket.user_availability_service() | ||||
|         ) | ||||
| 
 | ||||
|     async def snode_sync(self, app): | ||||
|         self.sync_service = asyncio.create_task(snode.sync_service(app)) | ||||
| 
 | ||||
|     async def start_ssh_server(self, app): | ||||
|         app.ssh_server = await start_ssh_server(app,app.ssh_host,app.ssh_port) | ||||
|         app.ssh_server = await start_ssh_server(app, app.ssh_host, app.ssh_port) | ||||
|         if app.ssh_server: | ||||
|             asyncio.create_task(app.ssh_server.wait_closed()) | ||||
| 
 | ||||
| @ -242,6 +256,7 @@ class Application(BaseApplication): | ||||
|         self.router.add_view("/settings/index.html", SettingsIndexView) | ||||
|         self.router.add_view("/settings/profile.html", SettingsProfileView) | ||||
|         self.router.add_view("/settings/profile.json", SettingsProfileView) | ||||
|         self.router.add_view("/push.json", PushView) | ||||
|         self.router.add_view("/web.html", WebView) | ||||
|         self.router.add_view("/login.html", LoginView) | ||||
|         self.router.add_view("/login.json", LoginView) | ||||
| @ -281,9 +296,9 @@ class Application(BaseApplication): | ||||
|         self.webdav = WebdavApplication(self) | ||||
|         self.git = GitApplication(self) | ||||
|         self.add_subapp("/webdav", self.webdav) | ||||
|         self.add_subapp("/git",self.git) | ||||
|         self.add_subapp("/git", self.git) | ||||
| 
 | ||||
|         #self.router.add_get("/{file_path:.*}", self.static_handler) | ||||
|         # self.router.add_get("/{file_path:.*}", self.static_handler) | ||||
| 
 | ||||
|     async def handle_test(self, request): | ||||
| 
 | ||||
| @ -313,7 +328,6 @@ class Application(BaseApplication): | ||||
|             async for subscribed_channel in self.services.channel_member.find( | ||||
|                 user_uid=request.session.get("uid"), deleted_at=None, is_banned=False | ||||
|             ): | ||||
|                  | ||||
|                 parent_object = await subscribed_channel.get_channel() | ||||
| 
 | ||||
|                 item = {} | ||||
| @ -365,9 +379,8 @@ class Application(BaseApplication): | ||||
|         #rendered.headers['Content-Lenght'] = len(rendered.text) | ||||
|         return rendered | ||||
| 
 | ||||
| 
 | ||||
|     async def static_handler(self, request): | ||||
|         file_name = request.match_info.get('filename', '') | ||||
|         file_name = request.match_info.get("filename", "") | ||||
| 
 | ||||
|         paths = [] | ||||
| 
 | ||||
| @ -401,7 +414,6 @@ class Application(BaseApplication): | ||||
|             if user_template_path: | ||||
|                 template_paths.append(user_template_path) | ||||
| 
 | ||||
| 
 | ||||
|         template_paths.append(self.template_path) | ||||
|         return FileSystemLoader(template_paths) | ||||
| 
 | ||||
| @ -410,7 +422,9 @@ app = Application(db_path="sqlite:///snek.db") | ||||
| 
 | ||||
| 
 | ||||
| async def main(): | ||||
|     await web._run_app(app, port=8081, host="0.0.0.0") | ||||
|     ssl_context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) | ||||
|     ssl_context.load_cert_chain('cert.pem', 'key.pem') | ||||
|     await web._run_app(app, port=8081, host="0.0.0.0", ssl_context=ssl_context) | ||||
| 
 | ||||
| 
 | ||||
| if __name__ == "__main__": | ||||
|  | ||||
| @ -9,6 +9,7 @@ from snek.mapper.notification import NotificationMapper | ||||
| from snek.mapper.user import UserMapper | ||||
| from snek.mapper.user_property import UserPropertyMapper | ||||
| from snek.mapper.repository import RepositoryMapper | ||||
| from snek.mapper.push import PushMapper | ||||
| from snek.mapper.channel_attachment import ChannelAttachmentMapper | ||||
| from snek.mapper.container import ContainerMapper | ||||
| from snek.system.object import Object | ||||
| @ -30,6 +31,7 @@ def get_mappers(app=None): | ||||
|             "repository": RepositoryMapper(app=app), | ||||
|             "channel_attachment": ChannelAttachmentMapper(app=app), | ||||
|             "container": ContainerMapper(app=app), | ||||
|             "push": PushMapper(app=app), | ||||
|         } | ||||
|     ) | ||||
| 
 | ||||
|  | ||||
							
								
								
									
										7
									
								
								src/snek/mapper/push.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								src/snek/mapper/push.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,7 @@ | ||||
| from snek.model.push_registration import PushRegistrationModel | ||||
| from snek.system.mapper import BaseMapper | ||||
| 
 | ||||
| 
 | ||||
| class PushMapper(BaseMapper): | ||||
|     model_class = PushRegistrationModel | ||||
|     table_name = "push_registration" | ||||
| @ -12,6 +12,7 @@ from snek.model.user import UserModel | ||||
| from snek.model.user_property import UserPropertyModel | ||||
| from snek.model.repository import RepositoryModel | ||||
| from snek.model.channel_attachment import ChannelAttachmentModel | ||||
| from snek.model.push_registration import PushRegistrationModel | ||||
| from snek.model.container import Container | ||||
| from snek.system.object import Object | ||||
| 
 | ||||
| @ -31,6 +32,7 @@ def get_models(): | ||||
|             "repository": RepositoryModel, | ||||
|             "channel_attachment": ChannelAttachmentModel, | ||||
|             "container": Container, | ||||
|             "push_registration": PushRegistrationModel, | ||||
|         } | ||||
|     ) | ||||
| 
 | ||||
|  | ||||
							
								
								
									
										8
									
								
								src/snek/model/push_registration.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								src/snek/model/push_registration.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,8 @@ | ||||
| from snek.system.model import BaseModel, ModelField | ||||
| 
 | ||||
| 
 | ||||
| class PushRegistrationModel(BaseModel): | ||||
|     user_uid = ModelField(name="user_uid", required=True) | ||||
|     endpoint = ModelField(name="endpoint", required=True) | ||||
|     key_auth = ModelField(name="key_auth", required=True) | ||||
|     key_p256dh = ModelField(name="key_p256dh", required=True) | ||||
| @ -9,6 +9,7 @@ from snek.service.drive_item import DriveItemService | ||||
| from snek.service.notification import NotificationService | ||||
| from snek.service.socket import SocketService | ||||
| from snek.service.user import UserService | ||||
| from snek.service.push import PushService | ||||
| from snek.service.user_property import UserPropertyService | ||||
| from snek.service.util import UtilService | ||||
| from snek.service.repository import RepositoryService | ||||
| @ -17,6 +18,7 @@ from snek.service.container import ContainerService | ||||
| from snek.system.object import Object | ||||
| from snek.service.db import DBService | ||||
| 
 | ||||
| 
 | ||||
| @functools.cache | ||||
| def get_services(app): | ||||
|     return Object( | ||||
| @ -36,6 +38,7 @@ def get_services(app): | ||||
|             "db": DBService(app=app), | ||||
|             "channel_attachment": ChannelAttachmentService(app=app), | ||||
|             "container": ContainerService(app=app), | ||||
|             "push": PushService(app=app), | ||||
|         } | ||||
|     ) | ||||
| 
 | ||||
|  | ||||
| @ -62,4 +62,18 @@ class NotificationService(BaseService): | ||||
|             except Exception: | ||||
|                 raise Exception(f"Failed to create notification: {model.errors}.") | ||||
| 
 | ||||
|             if channel_member["user_uid"] != user["uid"]: | ||||
|                 try: | ||||
|                     await self.app.services.push.notify_user( | ||||
|                         user_uid=channel_member["user_uid"], | ||||
|                         payload={ | ||||
|                             "title": f"New message in {channel_member['label']}", | ||||
|                             "message": f"{user['nick']}: {channel_message['message']}", | ||||
|                             "icon": "/image/snek192.png", | ||||
|                             "url": f"/channel/{channel_message['channel_uid']}.html", | ||||
|                         }, | ||||
|                     ) | ||||
|                 except Exception as e: | ||||
|                     print(f"Failed to send push notification:", e) | ||||
| 
 | ||||
|         self.app.db.commit() | ||||
|  | ||||
							
								
								
									
										271
									
								
								src/snek/service/push.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										271
									
								
								src/snek/service/push.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,271 @@ | ||||
| import json | ||||
| 
 | ||||
| import aiohttp | ||||
| from snek.system.service import BaseService | ||||
| import random | ||||
| import time | ||||
| import base64 | ||||
| import uuid | ||||
| from functools import cache | ||||
| from pathlib import Path | ||||
| 
 | ||||
| import jwt | ||||
| from cryptography.hazmat.primitives.asymmetric import ec | ||||
| from cryptography.hazmat.primitives import serialization | ||||
| from cryptography.hazmat.backends import default_backend | ||||
| from urllib.parse import urlparse | ||||
| import os.path | ||||
| 
 | ||||
| from cryptography.hazmat.primitives.ciphers.aead import AESGCM | ||||
| from cryptography.hazmat.primitives.hashes import SHA256 | ||||
| from cryptography.hazmat.primitives.kdf.hkdf import HKDF | ||||
| 
 | ||||
| # The only reason to persist the keys is to be able to use them in the web push | ||||
| 
 | ||||
| PRIVATE_KEY_FILE = Path("./notification-private.pem") | ||||
| PRIVATE_KEY_PKCS8_FILE = Path("./notification-private.pkcs8.pem") | ||||
| PUBLIC_KEY_FILE = Path("./notification-public.pem") | ||||
| 
 | ||||
| 
 | ||||
| def generate_private_key(): | ||||
|     if not PRIVATE_KEY_FILE.exists(): | ||||
|         private_key = ec.generate_private_key(ec.SECP256R1(), default_backend()) | ||||
| 
 | ||||
|         pem = private_key.private_bytes( | ||||
|             encoding=serialization.Encoding.PEM, | ||||
|             format=serialization.PrivateFormat.TraditionalOpenSSL, | ||||
|             encryption_algorithm=serialization.NoEncryption(), | ||||
|         ) | ||||
| 
 | ||||
|         PRIVATE_KEY_FILE.write_bytes(pem) | ||||
| 
 | ||||
| 
 | ||||
| def generate_pcks8_private_key(): | ||||
|     if not PRIVATE_KEY_PKCS8_FILE.exists(): | ||||
|         private_key = serialization.load_pem_private_key( | ||||
|             PRIVATE_KEY_FILE.read_bytes(), password=None, backend=default_backend() | ||||
|         ) | ||||
| 
 | ||||
|         pem = private_key.private_bytes( | ||||
|             encoding=serialization.Encoding.PEM, | ||||
|             format=serialization.PrivateFormat.PKCS8, | ||||
|             encryption_algorithm=serialization.NoEncryption(), | ||||
|         ) | ||||
| 
 | ||||
|         PRIVATE_KEY_PKCS8_FILE.write_bytes(pem) | ||||
| 
 | ||||
| 
 | ||||
| def generate_public_key(): | ||||
|     if not PUBLIC_KEY_FILE.exists(): | ||||
|         private_key = serialization.load_pem_private_key( | ||||
|             PRIVATE_KEY_FILE.read_bytes(), password=None, backend=default_backend() | ||||
|         ) | ||||
| 
 | ||||
|         public_key = private_key.public_key() | ||||
| 
 | ||||
|         pem = public_key.public_bytes( | ||||
|             encoding=serialization.Encoding.PEM, | ||||
|             format=serialization.PublicFormat.SubjectPublicKeyInfo, | ||||
|         ) | ||||
| 
 | ||||
|         PUBLIC_KEY_FILE.write_bytes(pem) | ||||
| 
 | ||||
| 
 | ||||
| def ensure_certificates(): | ||||
|     generate_private_key() | ||||
|     generate_pcks8_private_key() | ||||
|     generate_public_key() | ||||
| 
 | ||||
| 
 | ||||
| def hkdf(input_key, salt, info, length): | ||||
|     return HKDF( | ||||
|         algorithm=SHA256(), | ||||
|         length=length, | ||||
|         salt=salt, | ||||
|         info=info, | ||||
|         backend=default_backend(), | ||||
|     ).derive(input_key) | ||||
| 
 | ||||
| 
 | ||||
| def _browser_base64(data): | ||||
|     return base64.urlsafe_b64encode(data).decode("utf-8").rstrip("=") | ||||
| 
 | ||||
| 
 | ||||
| class PushService(BaseService): | ||||
|     mapper_name = "push" | ||||
| 
 | ||||
|     private_key_pem = None | ||||
|     public_key = None | ||||
|     public_key_base64 = None | ||||
| 
 | ||||
|     def __init__(self, *args, **kwargs): | ||||
|         super().__init__(*args, **kwargs) | ||||
|         ensure_certificates() | ||||
| 
 | ||||
|         private_key = serialization.load_pem_private_key( | ||||
|             PRIVATE_KEY_FILE.read_bytes(), password=None, backend=default_backend() | ||||
|         ) | ||||
|         self.private_key_pem = private_key.private_bytes( | ||||
|             encoding=serialization.Encoding.PEM, | ||||
|             format=serialization.PrivateFormat.TraditionalOpenSSL, | ||||
|             encryption_algorithm=serialization.NoEncryption(), | ||||
|         ) | ||||
| 
 | ||||
|         self.public_key = serialization.load_pem_public_key( | ||||
|             PUBLIC_KEY_FILE.read_bytes(), backend=default_backend() | ||||
|         ) | ||||
| 
 | ||||
|         self.public_key_base64 = _browser_base64( | ||||
|             self.public_key.public_bytes( | ||||
|                 encoding=serialization.Encoding.X962, | ||||
|                 format=serialization.PublicFormat.UncompressedPoint, | ||||
|             ) | ||||
|         ) | ||||
| 
 | ||||
|     def create_notification_authorization(self, push_url): | ||||
|         target = urlparse(push_url) | ||||
|         aud = f"{target.scheme}://{target.netloc}" | ||||
|         sub = "mailto:admin@molodetz.nl" | ||||
| 
 | ||||
|         identifier = str(uuid.uuid4()) | ||||
| 
 | ||||
|         print( | ||||
|             f"Creating notification authorization for {aud} with identifier {identifier}" | ||||
|         ) | ||||
| 
 | ||||
|         return jwt.encode( | ||||
|             { | ||||
|                 "sub": sub, | ||||
|                 "aud": aud, | ||||
|                 "exp": int(time.time()) + 60 * 60, | ||||
|                 "nbf": int(time.time()), | ||||
|                 "iat": int(time.time()), | ||||
|                 "jti": identifier, | ||||
|             }, | ||||
|             self.private_key_pem, | ||||
|             algorithm="ES256", | ||||
|         ) | ||||
| 
 | ||||
|     def create_notification_info_with_payload( | ||||
|         self, endpoint: str, auth: str, p256dh: str, payload: str | ||||
|     ): | ||||
|         message_private_key = ec.generate_private_key(ec.SECP256R1(), default_backend()) | ||||
| 
 | ||||
|         message_public_key_bytes = message_private_key.public_key().public_bytes( | ||||
|             encoding=serialization.Encoding.X962, | ||||
|             format=serialization.PublicFormat.UncompressedPoint, | ||||
|         ) | ||||
| 
 | ||||
|         salt = os.urandom(16) | ||||
| 
 | ||||
|         user_key_bytes = base64.urlsafe_b64decode(p256dh + "==") | ||||
|         shared_secret = message_private_key.exchange( | ||||
|             ec.ECDH(), | ||||
|             ec.EllipticCurvePublicKey.from_encoded_point( | ||||
|                 ec.SECP256R1(), user_key_bytes | ||||
|             ), | ||||
|         ) | ||||
| 
 | ||||
|         encryption_key = hkdf( | ||||
|             shared_secret, | ||||
|             base64.urlsafe_b64decode(auth + "=="), | ||||
|             b"Content-Encoding: auth\x00", | ||||
|             32, | ||||
|         ) | ||||
| 
 | ||||
|         context = ( | ||||
|             b"P-256\x00" | ||||
|             + len(user_key_bytes).to_bytes(2, "big") | ||||
|             + user_key_bytes | ||||
|             + len(message_public_key_bytes).to_bytes(2, "big") | ||||
|             + message_public_key_bytes | ||||
|         ) | ||||
| 
 | ||||
|         nonce = hkdf(encryption_key, salt, b"Content-Encoding: nonce\x00" + context, 12) | ||||
|         content_encryption_key = hkdf( | ||||
|             encryption_key, salt, b"Content-Encoding: aesgcm\x00" + context, 16 | ||||
|         ) | ||||
| 
 | ||||
|         padding_length = random.randint(0, 16) | ||||
|         padding = padding_length.to_bytes(2, "big") + b"\x00" * padding_length | ||||
| 
 | ||||
|         data = AESGCM(content_encryption_key).encrypt( | ||||
|             nonce, padding + payload.encode("utf-8"), None | ||||
|         ) | ||||
| 
 | ||||
|         return { | ||||
|             "headers": { | ||||
|                 "Authorization": f"WebPush {self.create_notification_authorization(endpoint)}", | ||||
|                 "Crypto-Key": f"dh={_browser_base64(message_public_key_bytes)}; p256ecdsa={self.public_key_base64}", | ||||
|                 "Encryption": f"salt={_browser_base64(salt)}", | ||||
|                 "Content-Encoding": "aesgcm", | ||||
|                 "Content-Length": str(len(data)), | ||||
|                 "Content-Type": "application/octet-stream", | ||||
|             }, | ||||
|             "data": data, | ||||
|         } | ||||
| 
 | ||||
|     async def notify_user(self, user_uid: str, payload: dict): | ||||
|         async with aiohttp.ClientSession() as session: | ||||
|             async for subscription in self.find(user_uid=user_uid): | ||||
|                 endpoint = subscription["endpoint"] | ||||
|                 key_auth = subscription["key_auth"] | ||||
|                 key_p256dh = subscription["key_p256dh"] | ||||
| 
 | ||||
|                 notification_info = self.create_notification_info_with_payload( | ||||
|                     endpoint, key_auth, key_p256dh, json.dumps(payload) | ||||
|                 ) | ||||
| 
 | ||||
|                 headers = { | ||||
|                     **notification_info["headers"], | ||||
|                     "TTL": "60", | ||||
|                 } | ||||
|                 data = notification_info["data"] | ||||
| 
 | ||||
|                 async with session.post( | ||||
|                     endpoint, | ||||
|                     headers=headers, | ||||
|                     data=data, | ||||
|                 ) as response: | ||||
|                     if response.status == 201 or response.status == 200: | ||||
|                         print( | ||||
|                             f"Notification sent to user {user_uid} via endpoint {endpoint}" | ||||
|                         ) | ||||
|                     else: | ||||
|                         print( | ||||
|                             f"Failed to send notification to user {user_uid} via endpoint {endpoint}: {response.status}" | ||||
|                         ) | ||||
|             else: | ||||
|                 print(f"No push subscriptions found for user {user_uid}") | ||||
| 
 | ||||
|     async def register( | ||||
|         self, user_uid: str, endpoint: str, key_auth: str, key_p256dh: str | ||||
|     ): | ||||
|         if await self.exists( | ||||
|             user_uid=user_uid, | ||||
|             endpoint=endpoint, | ||||
|             key_auth=key_auth, | ||||
|             key_p256dh=key_p256dh, | ||||
|         ): | ||||
|             return | ||||
| 
 | ||||
|         model = await self.new() | ||||
|         model["user_uid"] = user_uid | ||||
|         model["endpoint"] = endpoint | ||||
|         model["key_auth"] = key_auth | ||||
|         model["key_p256dh"] = key_p256dh | ||||
| 
 | ||||
|         print( | ||||
|             f"Registering push subscription for user {user_uid} with endpoint {endpoint}" | ||||
|         ) | ||||
| 
 | ||||
|         if await self.save(model=model) and model: | ||||
|             print( | ||||
|                 f"Push subscription registered for user {user_uid} with endpoint {endpoint}" | ||||
|             ) | ||||
| 
 | ||||
|             return model | ||||
| 
 | ||||
|         raise Exception( | ||||
|             f"Failed to register push subscription for user {user_uid} with endpoint {endpoint}" | ||||
|         ) | ||||
| @ -1,34 +1,57 @@ | ||||
| this.onpush = (event) => { | ||||
|   console.log(event.data); | ||||
|   // From here we can write the data to IndexedDB, send it to any open
 | ||||
|   // windows, display a notification, etc.
 | ||||
| }; | ||||
| 
 | ||||
| navigator.serviceWorker | ||||
| export const registerServiceWorker = async (silent = false) => { | ||||
|     try { | ||||
|         const serviceWorkerRegistration = await navigator.serviceWorker | ||||
|             .register("/service-worker.js") | ||||
|   .then((serviceWorkerRegistration) => { | ||||
|     serviceWorkerRegistration.pushManager.subscribe().then( | ||||
|       (pushSubscription) => { | ||||
| 
 | ||||
|         await serviceWorkerRegistration.update() | ||||
| 
 | ||||
|         await navigator.serviceWorker.ready | ||||
| 
 | ||||
|         const keyResponse = await fetch('/push.json') | ||||
|         const keyData = await keyResponse.json() | ||||
| 
 | ||||
|         const publicKey = Uint8Array.from(atob(keyData.publicKey), c => c.charCodeAt(0)) | ||||
| 
 | ||||
|         const pushSubscription = await serviceWorkerRegistration.pushManager.subscribe({ | ||||
|             userVisibleOnly: true, applicationServerKey: publicKey, | ||||
|         }) | ||||
| 
 | ||||
|         const subscriptionObject = { | ||||
|           endpoint: pushSubscription.endpoint, | ||||
|           keys: { | ||||
|             p256dh: pushSubscription.getKey("p256dh"), | ||||
|             auth: pushSubscription.getKey("auth"), | ||||
|           }, | ||||
|           encoding: PushManager.supportedContentEncodings, | ||||
|           /* other app-specific data, such as user identity */ | ||||
|             ...pushSubscription.toJSON(), encoding: PushManager.supportedContentEncodings, | ||||
|         }; | ||||
|         console.log( | ||||
|           pushSubscription.endpoint, | ||||
|           pushSubscription, | ||||
|           subscriptionObject, | ||||
|         ); | ||||
|         // The push subscription details needed by the application
 | ||||
|         // server are now available, and can be sent to it using,
 | ||||
|         // for example, the fetch() API.
 | ||||
|       }, | ||||
|       (error) => { | ||||
|         console.error(error); | ||||
|       }, | ||||
|     ); | ||||
|         console.log(pushSubscription.endpoint, pushSubscription, pushSubscription.toJSON(), subscriptionObject); | ||||
| 
 | ||||
|         const response = await fetch('/push.json', { | ||||
|             method: 'POST', headers: { | ||||
|                 'Content-Type': 'application/json', | ||||
|             }, body: JSON.stringify(subscriptionObject), | ||||
|         }) | ||||
| 
 | ||||
|         if (!response.ok) { | ||||
|             throw new Error('Bad status code from server.'); | ||||
|         } | ||||
| 
 | ||||
|         const responseData = await response.json(); | ||||
|         console.log('Registration response', responseData); | ||||
|     } catch (error) { | ||||
|         console.error("Error registering service worker:", error); | ||||
|         if (!silent) { | ||||
|             alert("Registering push notifications failed. Please check your browser settings and try again.\n\n" + error); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| 
 | ||||
| window.registerNotificationsServiceWorker = () => { | ||||
|     return Notification.requestPermission().then((permission) => { | ||||
|         if (permission === "granted") { | ||||
|             console.log("Permission was granted"); | ||||
|             return registerServiceWorker(); | ||||
|         } else if (permission === "denied") { | ||||
|             console.log("Permission was denied"); | ||||
|         } else { | ||||
|             console.log("Permission was dismissed"); | ||||
|         } | ||||
|     }); | ||||
| }; | ||||
| registerServiceWorker(true).catch(console.error); | ||||
|  | ||||
| @ -1,65 +1,66 @@ | ||||
| async function requestNotificationPermission() { | ||||
|   const permission = await Notification.requestPermission(); | ||||
|   return permission === "granted"; | ||||
| } | ||||
| 
 | ||||
| // Subscribe to Push Notifications
 | ||||
| async function subscribeUser() { | ||||
|   const registration = | ||||
|     await navigator.serviceWorker.register("/service-worker.js"); | ||||
| 
 | ||||
|   const subscription = await registration.pushManager.subscribe({ | ||||
|     userVisibleOnly: true, | ||||
|     applicationServerKey: urlBase64ToUint8Array(PUBLIC_VAPID_KEY), | ||||
|   }); | ||||
| 
 | ||||
|   // Send subscription to your backend
 | ||||
|   await fetch("/subscribe", { | ||||
|     method: "POST", | ||||
|     body: JSON.stringify(subscription), | ||||
|     headers: { | ||||
|       "Content-Type": "application/json", | ||||
|     }, | ||||
|   }); | ||||
| } | ||||
| 
 | ||||
| // Service Worker (service-worker.js)
 | ||||
| self.addEventListener("push", (event) => { | ||||
|   const data = event.data.json(); | ||||
|   self.registration.showNotification(data.title, { | ||||
|     body: data.message, | ||||
|     icon: data.icon, | ||||
|   }); | ||||
| }); | ||||
| 
 | ||||
| /* | ||||
| self.addEventListener("install", (event) => { | ||||
|   console.log("Service worker installed"); | ||||
|     console.log("Service worker installing..."); | ||||
|     event.waitUntil( | ||||
|         caches.open("snek-cache").then((cache) => { | ||||
|             return cache.addAll([]); | ||||
|         }) | ||||
|     ); | ||||
| }) | ||||
| 
 | ||||
| self.addEventListener("activate", (event) => { | ||||
|   event.waitUntil(self.registration?.navigationPreload.enable()); | ||||
| }); | ||||
| 
 | ||||
| self.addEventListener("push", (event) => { | ||||
|   if (!(self.Notification && self.Notification.permission === "granted")) { | ||||
|     if (!self.Notification || self.Notification.permission !== "granted") { | ||||
|         console.log("Notification permission not granted"); | ||||
|         return; | ||||
|     } | ||||
|   console.log("Received a push message", event); | ||||
| 
 | ||||
|     const data = event.data?.json() ?? {}; | ||||
|     console.log("Received a push message", event, data); | ||||
| 
 | ||||
|     const title = data.title || "Something Has Happened"; | ||||
|     const message = | ||||
|         data.message || "Here's something you might want to check out."; | ||||
|   const icon = "images/new-notification.png"; | ||||
|     const icon = data.icon || "/image/snek512.png"; | ||||
| 
 | ||||
|   event.waitUntil(self.registration.showNotification(title, { | ||||
|     const notificationSettings = data.notificationSettings || {}; | ||||
| 
 | ||||
|     console.log("Showing message", title, message, icon); | ||||
| 
 | ||||
|     const reg = self.registration.showNotification(title, { | ||||
|         body: message, | ||||
|     tag: "simple-push-demo-notification", | ||||
|         tag: "message-received", | ||||
|         icon, | ||||
|   })); | ||||
|         badge: icon, | ||||
|         ...notificationSettings, | ||||
|         data, | ||||
|     }).then(e => console.log("Showing notification", e)).catch(console.error); | ||||
| 
 | ||||
|     event.waitUntil(reg); | ||||
| }); | ||||
| 
 | ||||
| self.addEventListener("notificationclick", (event) => { | ||||
|     console.log("Notification click Received.", event); | ||||
|     event.notification.close(); | ||||
|     event.waitUntil(clients.openWindow( | ||||
|       "https://snek.molodetz.nl",)); | ||||
| });*/ | ||||
|     event.waitUntil(clients.openWindow(`${event.notification.data.url || event.notification.data.link || `/web.html`}`)); | ||||
| }); | ||||
| 
 | ||||
| self.addEventListener("notificationclose", (event) => { | ||||
|     console.log("Notification closed", event); | ||||
| }) | ||||
| 
 | ||||
| self.addEventListener("fetch", (event) => { | ||||
|     // console.log("Fetch event for ", event.request.url);
 | ||||
|     event.respondWith( | ||||
|         caches.match(event.request).then((response) => { | ||||
|             if (response) { | ||||
|                 // console.log("Found response in cache: ", response);
 | ||||
|                 return response; | ||||
|             } | ||||
|             // console.log("No response found in cache. About to fetch from network...");
 | ||||
|             return fetch(event.request); | ||||
|         }) | ||||
|     ); | ||||
| }) | ||||
| @ -7,9 +7,7 @@ | ||||
|   <title>Snek</title> | ||||
|   <style>{{highlight_styles}}</style> | ||||
|   <script src="/polyfills/Promise.withResolvers.js" type="module"></script> | ||||
|   <!--  | ||||
|   <script src="/push.js"></script> | ||||
|   --> | ||||
|   <script src="/push.js" type="module"></script> | ||||
|   <script src="/fancy-button.js" type="module"></script> | ||||
|   <script src="/upload-button.js" type="module"></script> | ||||
|   <script src="/generic-form.js" type="module"></script> | ||||
| @ -34,9 +32,6 @@ | ||||
| <script defer src="https://umami.molodetz.nl/script.js" data-website-id="d127c3e4-dc70-4041-a1c8-bcc32c2492ea"></script> | ||||
| </head> | ||||
| <body> | ||||
| 
 | ||||
| 
 | ||||
| 
 | ||||
|   <header> | ||||
|     <div class="logo no-select">{% block header_text %}{% endblock %}</div> | ||||
|     <nav class="no-select" style="overflow:hidden;scroll-behavior:smooth"> | ||||
| @ -46,6 +41,7 @@ | ||||
|       <a class="no-select" style="display:none" id="install-button" href="#">📥</a> | ||||
|       <a class="no-select" href="/threads.html">👥</a> | ||||
|       <a class="no-select" href="/settings/index.html">⚙️</a> | ||||
|       <a class="no-select" href="#" onclick="registerNotificationsServiceWorker()">✉️</a> | ||||
|       <a class="no-select" href="/logout.html">🔒</a> | ||||
|     </nav> | ||||
| 
 | ||||
| @ -54,7 +50,7 @@ | ||||
|     {% block sidebar %} | ||||
|     {% include "sidebar_channels.html" %} | ||||
|     {% endblock %} | ||||
|   <main> | ||||
| <main> | ||||
| 
 | ||||
|     {% block main %} | ||||
|     <chat-window class="chat-area"></chat-window> | ||||
|  | ||||
							
								
								
									
										86
									
								
								src/snek/view/push.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										86
									
								
								src/snek/view/push.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,86 @@ | ||||
| import base64 | ||||
| import json | ||||
| 
 | ||||
| import aiohttp | ||||
| from cryptography.hazmat.primitives import serialization | ||||
| 
 | ||||
| from snek.system.view import BaseFormView | ||||
| 
 | ||||
| 
 | ||||
| class PushView(BaseFormView): | ||||
|     async def get(self): | ||||
|         return await self.json_response( | ||||
|             { | ||||
|                 "publicKey": base64.b64encode( | ||||
|                     self.app.services.push.public_key.public_bytes( | ||||
|                         encoding=serialization.Encoding.X962, | ||||
|                         format=serialization.PublicFormat.UncompressedPoint, | ||||
|                     ) | ||||
|                 ) | ||||
|                 .decode("utf-8") | ||||
|                 .rstrip("="), | ||||
|             } | ||||
|         ) | ||||
| 
 | ||||
|     async def post(self): | ||||
|         user_id = self.session.get("uid") | ||||
|         if user_id: | ||||
|             user = await self.app.services.user.get(uid=user_id) | ||||
|             if not user: | ||||
|                 return await self.json_response({"error": "User not found"}, status=404) | ||||
| 
 | ||||
|             body = await self.request.json() | ||||
| 
 | ||||
|             if not all( | ||||
|                 [ | ||||
|                     "encoding" in body, | ||||
|                     "endpoint" in body, | ||||
|                     "keys" in body, | ||||
|                     "p256dh" in body["keys"], | ||||
|                     "auth" in body["keys"], | ||||
|                 ] | ||||
|             ): | ||||
|                 return await self.json_response( | ||||
|                     {"error": "Invalid request"}, status=400 | ||||
|                 ) | ||||
| 
 | ||||
|             regist = await self.app.services.push.register( | ||||
|                 user_uid=user_id, | ||||
|                 endpoint=body["endpoint"], | ||||
|                 key_auth=body["keys"]["auth"], | ||||
|                 key_p256dh=body["keys"]["p256dh"], | ||||
|             ) | ||||
| 
 | ||||
|             if regist: | ||||
|                 test_payload = { | ||||
|                     "title": f"Welcome {user['nick']}!", | ||||
|                     "message": "You'll now receive notifications from Snek :D", | ||||
|                     "icon": "/image/snek192.png", | ||||
|                     "url": "/web.html", | ||||
|                 } | ||||
| 
 | ||||
|                 notification_info = ( | ||||
|                     self.app.services.push.create_notification_info_with_payload( | ||||
|                         body["endpoint"], | ||||
|                         body["keys"]["auth"], | ||||
|                         body["keys"]["p256dh"], | ||||
|                         json.dumps(test_payload), | ||||
|                     ) | ||||
|                 ) | ||||
| 
 | ||||
|                 headers = { | ||||
|                     **notification_info["headers"], | ||||
|                     "TTL": "60", | ||||
|                 } | ||||
| 
 | ||||
|                 async with aiohttp.ClientSession() as session: | ||||
|                     async with session.post( | ||||
|                         body["endpoint"], | ||||
|                         headers=headers, | ||||
|                         data=notification_info["data"], | ||||
|                     ) as post_notification: | ||||
|                         print(post_notification.status) | ||||
|                         print(post_notification.text) | ||||
|                         print(post_notification.headers) | ||||
| 
 | ||||
|         return await self.json_response({ "registered": True }) | ||||
		Loading…
	
		Reference in New Issue
	
	Block a user