diff --git a/examples/chat/assets/scripts.js b/examples/chat/assets/scripts.js new file mode 100644 index 0000000..24210d3 --- /dev/null +++ b/examples/chat/assets/scripts.js @@ -0,0 +1,192 @@ + +function get_room(name, token, on_history, on_message){ + + const ws = new WebSocket(`ws://localhost:3000?room=${name}&token=${token}`); + var status = 'pending'; + const connection = new Promise((resolve)=>{ + ws.onopen = function(){ + status = 'connected'; + resolve(true) + } + ws.onerror = function(){ + status = 'error'; + resolve(false) + } + }); + + ws.onclose = function(){ + status = 'closed'; + } + ws.onmessage = (event) => { + const message = JSON.parse(event.data); + if(message instanceof Array){ + on_history(message) + }else{ + on_message(message) + } + } + + return { + send(message){ + ws.send(JSON.stringify(message)); + }, + wait_connection(){ + return connection; + }, + is_connected(){ + return status === 'connected'; + }, + close(){ + ws.close() + } + } +} + +function get_utc_date(){ + return new Date().toLocaleTimeString('en-US', { hour12:false, hour: '2-digit', minute: '2-digit', timeZone: 'UTC', timeZoneName: 'short' }) +} + +function get_session() { + let name = "session="; + let decodedCookie = decodeURIComponent(document.cookie); + let ca = decodedCookie.split(';'); + for (let i = 0; i < ca.length; i++) { + let c = ca[i]; + while (c.charAt(0) == ' ') { + c = c.substring(1); + } + if (c.indexOf(name) == 0) { + return c.substring(name.length, c.length); + } + } + return ""; +} + +async function get_user() { + const session = get_session() + if (!session) return null; + try { + const response = await fetch('https://api.github.com/user', { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${session}`, + 'Accept': 'application/json' + } + }) + return await response?.json() + } catch (err) { + console.error(err); + return null; + } +} + +function request_login(){ + return document.location.href = 'https://github.com/login/oauth/authorize?scope=user:email&client_id=9481803176fb73d616b7'; +} +let current_room = null; +let last_room_name = 'general'; +async function send_message(event){ + if(event && event.key?.toLowerCase() !== 'enter') return; + + const message = document.querySelector("#chat-message"); + await current_room?.wait_connection(); + if(current_room?.is_connected()){ + current_room.send({ + text: message.value, + datetime: get_utc_date() + }) + //clean + message.value = '' + }else { + await open_room(last_room_name) + send_message(); + } +} + +async function open_room(name){ + last_room_name = name; + const user = await get_user(); + if(!user){ + return request_login(); + } + const chat = document.querySelector('.chat-messages'); + + const room = get_room(name, get_session(), (history)=> { + //clear + chat.innerHTML = ''; + //add messages + for(let message of history){ + chat.appendChild(format_message(message, user)); + } + chat.scroll(0, chat.scrollHeight) + }, (message)=>{ + //add message + chat.appendChild(format_message(message, user)); + + //trim size + while(chat.childNodes.length > 100){ + chat.firstChild.remove(); + } + chat.scroll(0, chat.scrollHeight) + }); + await room.wait_connection() + current_room = room; +} +const markdown = new showdown.Converter({simpleLineBreaks: true,openLinksInNewWindow: true, emoji: true, ghMentions: true, tables: true, strikethrough: true, tasklists: true}); + +function format_message(message, user){ + const message_element = document.createElement("div"); + if(message.login === user.login){ + message.name = 'You'; + message_element.classList.add('chat-message-right'); + }else{ + message_element.classList.add('chat-message-left'); + } + + message_element.classList.add('pb-4'); + + const header = document.createElement("div"); + image = new Image(40, 40); + image.src = message.avatar_url; + image.classList.add('rounded-circle'); + image.classList.add('mr-1'); + image.alt = message.name; + + const date = document.createElement("div"); + date.classList.add('text-muted'); + date.classList.add('small'); + date.classList.add('text-nowrap'); + date.classList.add('mt-2'); + date.textContent = message.datetime; + header.addEventListener("click", ()=> { + window.open(message.html_url, '_blank').focus(); + }); + header.appendChild(image); + header.appendChild(date); + + message_element.appendChild(header); + + const body = document.createElement("div"); + body.classList.add('flex-shrink-1'); + body.classList.add('bg-light'); + body.classList.add('rounded'); + body.classList.add('py-2'); + body.classList.add('px-3'); + body.classList.add('mr-3'); + + const author = document.createElement("div") + author.classList.add('font-weight-bold'); + author.classList.add('mb-1'); + author.textContent = message.name; + + + body.appendChild(author); + const content = document.createElement("div"); + content.innerHTML = markdown.makeHtml(message.text); + body.appendChild(content); + + message_element.appendChild(body); + + return message_element; +} diff --git a/examples/chat/assets/styles.css b/examples/chat/assets/styles.css new file mode 100644 index 0000000..7ba9ea0 --- /dev/null +++ b/examples/chat/assets/styles.css @@ -0,0 +1,45 @@ +body{margin-top:20px;} + +.chat-online { + color: #34ce57 +} + +.chat-offline { + color: #e4606d +} + +.chat-messages { + display: flex; + flex-direction: column; + max-height: 800px; + overflow-y: scroll +} + +.chat-message-left, +.chat-message-right { + display: flex; + flex-shrink: 0 +} + +.chat-message-left { + margin-right: auto +} + +.chat-message-right { + flex-direction: row-reverse; + margin-left: auto +} +.py-3 { + padding-top: 1rem!important; + padding-bottom: 1rem!important; +} +.px-4 { + padding-right: 1.5rem!important; + padding-left: 1.5rem!important; +} +.flex-grow-0 { + flex-grow: 0!important; +} +.border-top { + border-top: 1px solid #dee2e6!important; +} \ No newline at end of file diff --git a/examples/chat/index.html b/examples/chat/index.html new file mode 100644 index 0000000..583fe48 --- /dev/null +++ b/examples/chat/index.html @@ -0,0 +1,82 @@ + + + + + + + socketify.py | chat + + + + + + + + + + + +
+
+ +

Socketify.py Chat

+ +
+ +
+
+
+ + \ No newline at end of file diff --git a/examples/chat/index.py b/examples/chat/index.py new file mode 100644 index 0000000..01c3214 --- /dev/null +++ b/examples/chat/index.py @@ -0,0 +1,138 @@ +from socketify import App, sendfile, CompressOptions, OpCode +import aiohttp +import json +from datetime import datetime, timedelta + +app = App() +def get_welcome_message(room): + return { + "username": "@cirospaciari/socketify.py", + "html_url": "https://www.github.com/cirospaciari/socketify.py", + "avatar_url": "https://raw.githubusercontent.com/cirospaciari/socketify.py/main/misc/big_logo.png", + "datetime": "", + "name": "socketify.py", + "text": f"Welcome to chat room #{room} :heart: be nice! :tada:" +} +RoomsHistory = { + "general": [get_welcome_message("general")], + "open-source": [get_welcome_message("open-source")], + "reddit": [get_welcome_message("reddit")] +} + +# send home page index.html +async def home(res, req): + # Get Code for GitHub Auth + code = req.get_query("code") + if code is not None: + try: + # Get AccessToken for Auth + async with aiohttp.ClientSession() as session: + async with session.post( + "https://github.com/login/oauth/access_token", + data={ + "client_id": "9481803176fb73d616b7", + "client_secret": "???", + "code": code, + }, + headers={"Accept": "application/json"}, + ) as response: + user = await response.json() + session = user.get("access_token", None) + res.set_cookie( + "session", + session, + { + "path": "/", + "httponly": False, + "expires": datetime.utcnow() + timedelta(minutes=30), + }, + ) + except Exception as error: + print(str(error)) # login fail + + # sends the whole file with 304 and bytes range support + await sendfile(res, req, "./index.html") + + + +async def ws_upgrade(res, req, socket_context): + key = req.get_header("sec-websocket-key") + protocol = req.get_header("sec-websocket-protocol") + extensions = req.get_header("sec-websocket-extensions") + token = req.get_query("token") + room = req.get_query("room") + if RoomsHistory.get(room, None) is None: + return res.write_status(403).end("invalid room") + + try: + async with aiohttp.ClientSession() as session: + async with session.get( + "https://api.github.com/user", + headers={ + "Accept": "application/json", + "Content-Type": "application/json", + "Authorization": f"Bearer {token}", + }, + ) as response: + user_data = await response.json() + if user_data.get('login', None) is not None: + user_data["room"] = room + res.upgrade(key, protocol, extensions, socket_context, user_data) + else: + res.write_status(403).end("auth fail") + except Exception as error: + print(str(error)) # auth error + res.write_status(403).end("auth fail") + + +def ws_open(ws): + user_data = ws.get_user_data() + room = user_data.get("room", "general") + ws.subscribe(room) + + history = RoomsHistory.get(room, []) + if history: + ws.send(history, OpCode.TEXT) + +def ws_message(ws, message, opcode): + try: + message_data = json.loads(message) + text = message_data.get('text', None) + if text and len(text) < 1024 and message_data.get('datetime', None): + user_data = ws.get_user_data() + room = user_data.get("room", "general") + + message_data.update(user_data) + history = RoomsHistory.get(room, []) + if history: + history.append(message_data) + if len(history) > 100: + history = history[::100] + + #broadcast + ws.send(message_data, OpCode.TEXT) + ws.publish(room, message_data, OpCode.TEXT) + + except: + pass + +app.get("/", home) +# serve all files in assets folder under /assets/* route +app.static("/assets", "./assets") + +app.ws( + "/*", + { + "compression": CompressOptions.SHARED_COMPRESSOR, + "max_payload_length": 64 * 1024, + "idle_timeout": 60 * 30, + "open": ws_open, + "message": ws_message, + "upgrade": ws_upgrade + }, +) +app.listen( + 3000, + lambda config: print("Listening on port http://localhost:%d now\n" % config.port), +) +app.run() diff --git a/examples/router_and_basics.py b/examples/router_and_basics.py index 678d36a..90ebc19 100644 --- a/examples/router_and_basics.py +++ b/examples/router_and_basics.py @@ -1,7 +1,6 @@ from socketify import App, AppOptions, AppListenOptions import asyncio -from datetime import datetime -from datetime import timedelta +from datetime import datetime, timedelta app = App() diff --git a/src/socketify/helpers.py b/src/socketify/helpers.py index 0837a23..a68ae4a 100644 --- a/src/socketify/helpers.py +++ b/src/socketify/helpers.py @@ -97,18 +97,23 @@ def static_route(app, route, directory): def route_handler(res, req): url = req.get_url() res.grab_aborted_handler() - url = url[len(route) : :] - if url.startswith("/"): - url = url[1::] - filename = path.join(path.realpath(directory), url) + url = url[len(route)::] + if url.endswith("/"): + if url.startswith("/"): + url = url[1:-1] + else: + url = url[:-1] + elif url.startswith("/"): + url = url[1:] + filename = path.join(path.realpath(directory), url) if not in_directory(filename, directory): res.write_status(404).end_without_body() return res.run_async(sendfile(res, req, filename)) - if route.startswith("/"): - route = route[1::] + if route.endswith("/"): + route = route[:-1] app.get("%s/*" % route, route_handler) diff --git a/src/socketify/socketify.py b/src/socketify/socketify.py index 5d92894..4ccc625 100644 --- a/src/socketify/socketify.py +++ b/src/socketify/socketify.py @@ -10,7 +10,7 @@ from os import path import platform import signal from threading import Thread, local, Lock -import time +import uuid from urllib.parse import parse_qs, quote_plus, unquote_plus from .loop import Loop @@ -775,7 +775,7 @@ class WebSocket: topics = [] def copy_topics(topic): - topics.append(value) + topics.append(topic) self.for_each_topic(copy_topics) return topics @@ -1062,7 +1062,7 @@ class AppRequest: url = self.get_url() query = self.get_full_url()[len(url) :] - if full_url.startswith("?"): + if query.startswith("?"): query = query[1:] self._query = parse_qs(query, encoding="utf-8") return self._query @@ -1612,7 +1612,7 @@ class AppResponse: user_data_ptr = ffi.NULL if not user_data is None: _id = uuid.uuid4() - user_data_ptr = (ffi.new_handle(user_data), _id) + user_data_ptr = ffi.new_handle((user_data, _id)) # keep alive data SocketRefs[_id] = user_data_ptr