Initial setup for push notifications (still has issues with fcm aka chrome/opera)
# Conflicts: # src/snek/templates/app.html # Conflicts: # src/snek/app.py # Conflicts: # src/snek/app.py # src/snek/templates/app.html # Conflicts: # src/snek/app.py # Conflicts: # src/snek/app.py # src/snek/static/push.js # src/snek/static/service-worker.js # src/snek/templates/app.html
This commit is contained in:
		
							parent
							
								
									4854d40508
								
							
						
					
					
						commit
						0057792802
					
				| @ -22,6 +22,8 @@ from app.app import Application as BaseApplication | |||||||
| from jinja2 import FileSystemLoader | from jinja2 import FileSystemLoader | ||||||
| 
 | 
 | ||||||
| from snek.sssh import start_ssh_server | from snek.sssh import start_ssh_server | ||||||
|  | 
 | ||||||
|  | from snek.system.notification import get_notifications | ||||||
| from snek.docs.app import Application as DocsApplication | from snek.docs.app import Application as DocsApplication | ||||||
| from snek.mapper import get_mappers | from snek.mapper import get_mappers | ||||||
| from snek.service import get_services | from snek.service import get_services | ||||||
| @ -38,6 +40,7 @@ from snek.view.drive import DriveView | |||||||
| from snek.view.drive import DriveApiView | from snek.view.drive import DriveApiView | ||||||
| from snek.view.index import IndexView | from snek.view.index import IndexView | ||||||
| from snek.view.login import LoginView | from snek.view.login import LoginView | ||||||
|  | from snek.view.push import PushView | ||||||
| from snek.view.logout import LogoutView | from snek.view.logout import LogoutView | ||||||
| from snek.view.register import RegisterView | from snek.view.register import RegisterView | ||||||
| from snek.view.rpc import RPCView | from snek.view.rpc import RPCView | ||||||
| @ -101,6 +104,8 @@ class Application(BaseApplication): | |||||||
|         self.time_start = datetime.now() |         self.time_start = datetime.now() | ||||||
|         self.ssh_host = "0.0.0.0" |         self.ssh_host = "0.0.0.0" | ||||||
|         self.ssh_port = 2242 |         self.ssh_port = 2242 | ||||||
|  | 
 | ||||||
|  |         get_notifications() | ||||||
|         self.setup_router() |         self.setup_router() | ||||||
|         self.ssh_server = None |         self.ssh_server = None | ||||||
|         self.sync_service = None |         self.sync_service = None | ||||||
| @ -208,6 +213,7 @@ class Application(BaseApplication): | |||||||
|         self.router.add_view("/settings/index.html", SettingsIndexView) |         self.router.add_view("/settings/index.html", SettingsIndexView) | ||||||
|         self.router.add_view("/settings/profile.html", SettingsProfileView) |         self.router.add_view("/settings/profile.html", SettingsProfileView) | ||||||
|         self.router.add_view("/settings/profile.json", 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("/web.html", WebView) | ||||||
|         self.router.add_view("/login.html", LoginView) |         self.router.add_view("/login.html", LoginView) | ||||||
|         self.router.add_view("/login.json", LoginView) |         self.router.add_view("/login.json", LoginView) | ||||||
|  | |||||||
| @ -1,31 +1,51 @@ | |||||||
| this.onpush = (event) => { | // this.onpush = (event) => {
 | ||||||
|   console.log(event.data); | //             console.log(event.data);
 | ||||||
|   // From here we can write the data to IndexedDB, send it to any open
 | //             // From here we can write the data to IndexedDB, send it to any open
 | ||||||
|   // windows, display a notification, etc.
 | //             // windows, display a notification, etc.
 | ||||||
| }; | //         };
 | ||||||
|  | 
 | ||||||
|  | function arrayBufferToBase64(buffer) { | ||||||
|  |     return btoa(String.fromCharCode(...new Uint8Array(buffer))) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | const keyResponse = await fetch('/push.json') | ||||||
|  | const keyData = await keyResponse.json() | ||||||
|  | 
 | ||||||
|  | const publicKey = Uint8Array.from(atob(keyData.publicKey), c => c.charCodeAt(0)) | ||||||
| 
 | 
 | ||||||
| navigator.serviceWorker | navigator.serviceWorker | ||||||
|     .register("/service-worker.js") |     .register("/service-worker.js") | ||||||
|     .then((serviceWorkerRegistration) => { |     .then((serviceWorkerRegistration) => { | ||||||
|     serviceWorkerRegistration.pushManager.subscribe().then( |         console.log(serviceWorkerRegistration); | ||||||
|  |         serviceWorkerRegistration.pushManager.subscribe({ | ||||||
|  |             userVisibleOnly: true, | ||||||
|  |             applicationServerKey: publicKey, | ||||||
|  |         }).then( | ||||||
|             (pushSubscription) => { |             (pushSubscription) => { | ||||||
|                 const subscriptionObject = { |                 const subscriptionObject = { | ||||||
|           endpoint: pushSubscription.endpoint, |                     ...pushSubscription.toJSON(), | ||||||
|           keys: { |  | ||||||
|             p256dh: pushSubscription.getKey("p256dh"), |  | ||||||
|             auth: pushSubscription.getKey("auth"), |  | ||||||
|           }, |  | ||||||
|                     encoding: PushManager.supportedContentEncodings, |                     encoding: PushManager.supportedContentEncodings, | ||||||
|                     /* other app-specific data, such as user identity */ |                     /* other app-specific data, such as user identity */ | ||||||
|                 }; |                 }; | ||||||
|         console.log( |                 console.log(pushSubscription.endpoint, pushSubscription, pushSubscription.toJSON(), subscriptionObject); | ||||||
|           pushSubscription.endpoint, |  | ||||||
|           pushSubscription, |  | ||||||
|           subscriptionObject, |  | ||||||
|         ); |  | ||||||
|                 // The push subscription details needed by the application
 |                 // The push subscription details needed by the application
 | ||||||
|                 // server are now available, and can be sent to it using,
 |                 // server are now available, and can be sent to it using,
 | ||||||
|                 // for example, the fetch() API.
 |                 // for example, the fetch() API.
 | ||||||
|  | 
 | ||||||
|  |                 fetch('/push.json', { | ||||||
|  |                     method: 'POST', | ||||||
|  |                     headers: { | ||||||
|  |                         'Content-Type': 'application/json', | ||||||
|  |                     }, | ||||||
|  |                     body: JSON.stringify(subscriptionObject), | ||||||
|  |                 }).then((response) => { | ||||||
|  |                     if (!response.ok) { | ||||||
|  |                         throw new Error('Bad status code from server.'); | ||||||
|  |                     } | ||||||
|  |                     return response.json(); | ||||||
|  |                 }).then((responseData) => { | ||||||
|  |                     console.log(responseData); | ||||||
|  |                 }); | ||||||
|             }, |             }, | ||||||
|             (error) => { |             (error) => { | ||||||
|                 console.error(error); |                 console.error(error); | ||||||
|  | |||||||
| @ -24,18 +24,14 @@ async function subscribeUser() { | |||||||
| } | } | ||||||
| 
 | 
 | ||||||
| // Service Worker (service-worker.js)
 | // Service Worker (service-worker.js)
 | ||||||
| self.addEventListener("push", (event) => { | // self.addEventListener("push", (event) => {
 | ||||||
|   const data = event.data.json(); | //   const data = event.data.json();
 | ||||||
|   self.registration.showNotification(data.title, { | //   self.registration.showNotification(data.title, {
 | ||||||
|     body: data.message, | //     body: data.message,
 | ||||||
|     icon: data.icon, | //     icon: data.icon,
 | ||||||
|   }); | //   });
 | ||||||
| }); | // });
 | ||||||
| 
 | 
 | ||||||
| /* |  | ||||||
| self.addEventListener("install", (event) => { |  | ||||||
|   console.log("Service worker installed"); |  | ||||||
| }); |  | ||||||
| 
 | 
 | ||||||
| self.addEventListener("push",  (event) => { | self.addEventListener("push",  (event) => { | ||||||
|   if (!(self.Notification && self.Notification.permission === "granted")) { |   if (!(self.Notification && self.Notification.permission === "granted")) { | ||||||
| @ -62,4 +58,4 @@ self.addEventListener("notificationclick", (event) => { | |||||||
|     event.notification.close(); |     event.notification.close(); | ||||||
|     event.waitUntil(clients.openWindow( |     event.waitUntil(clients.openWindow( | ||||||
|       "https://snek.molodetz.nl",)); |       "https://snek.molodetz.nl",)); | ||||||
| });*/ | }); | ||||||
|  | |||||||
							
								
								
									
										125
									
								
								src/snek/system/notification.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										125
									
								
								src/snek/system/notification.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,125 @@ | |||||||
|  | import time | ||||||
|  | import base64 | ||||||
|  | import uuid | ||||||
|  | from functools import cache | ||||||
|  | 
 | ||||||
|  | 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 | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | PRIVATE_KEY_FILE = './notification-private.pem' | ||||||
|  | PRIVATE_KEY_PKCS8_FILE = './notification-private.pkcs8.pem' | ||||||
|  | PUBLIC_KEY_FILE = './notification-public.pem' | ||||||
|  | 
 | ||||||
|  | def generate_private_key(): | ||||||
|  |     if not os.path.isfile(PRIVATE_KEY_FILE): | ||||||
|  |         private_key = ec.generate_private_key(ec.SECP256R1(), default_backend()) | ||||||
|  | 
 | ||||||
|  |         # Serialize the private key to PEM format | ||||||
|  |         pem = private_key.private_bytes( | ||||||
|  |             encoding=serialization.Encoding.PEM, | ||||||
|  |             format=serialization.PrivateFormat.TraditionalOpenSSL, | ||||||
|  |             encryption_algorithm=serialization.NoEncryption() | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  |         # Write the private key to a file | ||||||
|  |         with open(PRIVATE_KEY_FILE, 'wb') as pem_out: | ||||||
|  |             pem_out.write(pem) | ||||||
|  | 
 | ||||||
|  | def generate_pcks8_private_key(): | ||||||
|  |     # openssl pkcs8 -topk8 -nocrypt -in private.pem -out private.pkcs8.pem | ||||||
|  |     if not os.path.isfile(PRIVATE_KEY_PKCS8_FILE): | ||||||
|  |         with open(PRIVATE_KEY_FILE, 'rb') as pem_in: | ||||||
|  |             private_key = serialization.load_pem_private_key(pem_in.read(), password=None, backend=default_backend()) | ||||||
|  | 
 | ||||||
|  |         # Serialize the private key to PKCS8 format | ||||||
|  |         pem = private_key.private_bytes( | ||||||
|  |             encoding=serialization.Encoding.PEM, | ||||||
|  |             format=serialization.PrivateFormat.PKCS8, | ||||||
|  |             encryption_algorithm=serialization.NoEncryption() | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  |         # Write the private key to a file | ||||||
|  |         with open(PRIVATE_KEY_PKCS8_FILE, 'wb') as pem_out: | ||||||
|  |             pem_out.write(pem) | ||||||
|  | 
 | ||||||
|  | def generate_public_key(): | ||||||
|  |     if not os.path.isfile(PUBLIC_KEY_FILE): | ||||||
|  |         with open(PRIVATE_KEY_FILE, 'rb') as pem_in: | ||||||
|  |             private_key = serialization.load_pem_private_key(pem_in.read(), password=None, backend=default_backend()) | ||||||
|  | 
 | ||||||
|  |         # Get the public key from the private key | ||||||
|  |         public_key = private_key.public_key() | ||||||
|  | 
 | ||||||
|  |         # Serialize the public key to PEM format | ||||||
|  |         pem = public_key.public_bytes( | ||||||
|  |             encoding=serialization.Encoding.PEM, | ||||||
|  |             format=serialization.PublicFormat.SubjectPublicKeyInfo | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  |         # Write the public key to a file | ||||||
|  |         with open(PUBLIC_KEY_FILE, 'wb') as pem_out: | ||||||
|  |             pem_out.write(pem) | ||||||
|  | 
 | ||||||
|  | def ensure_certificates(): | ||||||
|  |     generate_private_key() | ||||||
|  |     generate_pcks8_private_key() | ||||||
|  |     generate_public_key() | ||||||
|  | 
 | ||||||
|  | class Notifications: | ||||||
|  |     private_key_pem = None | ||||||
|  |     public_key = None | ||||||
|  |     public_key_jwk = None | ||||||
|  | 
 | ||||||
|  |     def __init__(self): | ||||||
|  |         ensure_certificates() | ||||||
|  |         with open(PRIVATE_KEY_FILE, 'rb') as pem_in: | ||||||
|  |             private_key = serialization.load_pem_private_key(pem_in.read(), 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() | ||||||
|  |             ) | ||||||
|  | 
 | ||||||
|  |         with open(PUBLIC_KEY_FILE, 'rb') as pem_in: | ||||||
|  |             self.public_key = serialization.load_pem_public_key(pem_in.read(), backend=default_backend()) | ||||||
|  |             public_numbers = self.public_key.public_numbers() | ||||||
|  | 
 | ||||||
|  |             self.public_key_jwk = { | ||||||
|  |                 "kty": "EC", | ||||||
|  |                 "crv": "P-256", | ||||||
|  |                 "x": base64.urlsafe_b64encode(public_numbers.x.to_bytes(32, byteorder='big')).decode('utf-8'), | ||||||
|  |                 "y": base64.urlsafe_b64encode(public_numbers.y.to_bytes(32, byteorder='big')).decode('utf-8') | ||||||
|  |             } | ||||||
|  | 
 | ||||||
|  |         print(f"Public key JWK: {self.public_key_jwk}") | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  |     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') | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | @cache | ||||||
|  | def get_notifications(): | ||||||
|  |     return Notifications() | ||||||
| @ -7,9 +7,7 @@ | |||||||
|   <title>Snek</title> |   <title>Snek</title> | ||||||
|   <style>{{highlight_styles}}</style> |   <style>{{highlight_styles}}</style> | ||||||
|   <script src="/polyfills/Promise.withResolvers.js" type="module"></script> |   <script src="/polyfills/Promise.withResolvers.js" type="module"></script> | ||||||
|   <!--  |   <script src="/push.js" type="module"></script> | ||||||
|   <script src="/push.js"></script> |  | ||||||
|   --> |  | ||||||
|   <script src="/fancy-button.js" type="module"></script> |   <script src="/fancy-button.js" type="module"></script> | ||||||
|   <script src="/upload-button.js" type="module"></script> |   <script src="/upload-button.js" type="module"></script> | ||||||
|   <script src="/generic-form.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> | <script defer src="https://umami.molodetz.nl/script.js" data-website-id="d127c3e4-dc70-4041-a1c8-bcc32c2492ea"></script> | ||||||
| </head> | </head> | ||||||
| <body> | <body> | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
|   <header> |   <header> | ||||||
|     <div class="logo no-select">{% block header_text %}{% endblock %}</div> |     <div class="logo no-select">{% block header_text %}{% endblock %}</div> | ||||||
|     <nav class="no-select" style="overflow:hidden;scroll-behavior:smooth"> |     <nav class="no-select" style="overflow:hidden;scroll-behavior:smooth"> | ||||||
|  | |||||||
							
								
								
									
										124
									
								
								src/snek/view/push.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										124
									
								
								src/snek/view/push.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,124 @@ | |||||||
|  | # Written by retoor@molodetz.nl | ||||||
|  | 
 | ||||||
|  | # This code defines an async class-based view called StatusView for handling HTTP GET requests. It fetches user details and their associated channel memberships from a database and returns a JSON response with user information if the user is logged in. | ||||||
|  | 
 | ||||||
|  | # The code uses an imported module `BaseView`. There are dependencies on the `snek.system.view` module which provides the BaseView class. | ||||||
|  | 
 | ||||||
|  | # MIT License | ||||||
|  | # | ||||||
|  | # Permission is hereby granted, free of charge, to any person obtaining a copy | ||||||
|  | # of this software and associated documentation files (the "Software"), to deal | ||||||
|  | # in the Software without restriction, including without limitation the rights | ||||||
|  | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | ||||||
|  | # copies of the Software, and to permit persons to whom the Software is | ||||||
|  | # furnished to do so, subject to the following conditions: | ||||||
|  | # | ||||||
|  | # The above copyright notice and this permission notice shall be included in all | ||||||
|  | # copies or substantial portions of the Software. | ||||||
|  | # | ||||||
|  | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, | ||||||
|  | # INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A | ||||||
|  | # PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT | ||||||
|  | # HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF | ||||||
|  | # CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE | ||||||
|  | # OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | import base64 | ||||||
|  | 
 | ||||||
|  | import requests | ||||||
|  | from cryptography.hazmat.primitives import serialization | ||||||
|  | 
 | ||||||
|  | from snek.system.notification import get_notifications | ||||||
|  | from snek.system.view import BaseFormView | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class PushView(BaseFormView): | ||||||
|  |     async def get(self): | ||||||
|  |         notifications =get_notifications() | ||||||
|  | 
 | ||||||
|  |         return await self.json_response({ | ||||||
|  |             "publicKey": base64.b64encode( | ||||||
|  |                 notifications.public_key.public_bytes( | ||||||
|  |                     encoding=serialization.Encoding.X962, | ||||||
|  |                     format=serialization.PublicFormat.UncompressedPoint | ||||||
|  |                 ) | ||||||
|  |             ).decode('utf-8').rstrip("="), | ||||||
|  |         }) | ||||||
|  | 
 | ||||||
|  |     async def post(self): | ||||||
|  | 
 | ||||||
|  |         memberships = [] | ||||||
|  |         user = {} | ||||||
|  |          | ||||||
|  |         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 ("encoding" in body and "endpoint" in body and "keys" in body and "p256dh" in body["keys"] and "auth"  in body["keys"]): | ||||||
|  |                 return await self.json_response({"error": "Invalid request"}, status=400) | ||||||
|  | 
 | ||||||
|  |             print(body) | ||||||
|  |             notifications =get_notifications() | ||||||
|  | 
 | ||||||
|  |             cert = base64.b64encode( | ||||||
|  |                 notifications.public_key.public_bytes( | ||||||
|  |                     encoding=serialization.Encoding.X962, | ||||||
|  |                     format=serialization.PublicFormat.UncompressedPoint | ||||||
|  |                 ) | ||||||
|  |             ).decode('utf-8').rstrip("=") | ||||||
|  | 
 | ||||||
|  |             headers = { | ||||||
|  |                     "TTL": "60", | ||||||
|  |                     "Authorization": f"WebPush {notifications.create_notification_authorization(body['endpoint'])}", | ||||||
|  |                     "Crypto-Key": f"p256ecdsa={cert}", | ||||||
|  |             } | ||||||
|  | 
 | ||||||
|  |             print(headers) | ||||||
|  | 
 | ||||||
|  |             post_notification = requests.post( | ||||||
|  |                 body["endpoint"], | ||||||
|  |                 headers=headers) | ||||||
|  | 
 | ||||||
|  |             print(post_notification.status_code) | ||||||
|  |             print(post_notification.text) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  |             async for model in self.app.services.channel_member.find( | ||||||
|  |                 user_uid=user_id, deleted_at=None, is_banned=False | ||||||
|  |             ): | ||||||
|  |                 channel = await self.app.services.channel.get(uid=model["channel_uid"]) | ||||||
|  |                 memberships.append( | ||||||
|  |                     { | ||||||
|  |                         "name": channel["label"], | ||||||
|  |                         "description": model["description"], | ||||||
|  |                         "user_uid": model["user_uid"], | ||||||
|  |                         "is_moderator": model["is_moderator"], | ||||||
|  |                         "is_read_only": model["is_read_only"], | ||||||
|  |                         "is_muted": model["is_muted"], | ||||||
|  |                         "is_banned": model["is_banned"], | ||||||
|  |                         "channel_uid": model["channel_uid"], | ||||||
|  |                         "uid": model["uid"], | ||||||
|  |                     } | ||||||
|  |                 ) | ||||||
|  |             user = { | ||||||
|  |                 "username": user["username"], | ||||||
|  |                 "email": user["email"], | ||||||
|  |                 "nick": user["nick"], | ||||||
|  |                 "uid": user["uid"], | ||||||
|  |                 "color": user["color"], | ||||||
|  |                 "memberships": memberships, | ||||||
|  |             } | ||||||
|  | 
 | ||||||
|  |         return await self.json_response( | ||||||
|  |             { | ||||||
|  |                 "user": user, | ||||||
|  |                 "cache": await self.app.cache.create_cache_key( | ||||||
|  |                     self.app.cache.cache, None | ||||||
|  |                 ), | ||||||
|  |             } | ||||||
|  |         ) | ||||||
		Loading…
	
		Reference in New Issue
	
	Block a user
	 BordedDev
						BordedDev