diff --git a/README.md b/README.md index 1fcf8d1..acc3fa8 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@

Nightwatch

-![Python](https://img.shields.io/badge/Python-%3E=%203.10-4b8bbe?style=for-the-badge&logo=python&logoColor=white) +![Python](https://img.shields.io/badge/Python-%3E=%203.11-4b8bbe?style=for-the-badge&logo=python&logoColor=white) ![Rust](https://img.shields.io/badge/Rust-%3E=%201.60-221f1e?style=for-the-badge&logo=rust&logoColor=white) The chatting application to end all chatting applications. @@ -19,7 +19,7 @@ Here are two of the standard clients for you to choose from: - Full Desktop App ([based on tauri](https://tauri.app/)) - Download the latest release for your system from [here](https://github.com/iiPythonx/nightwatch/releases/latest). - Alternatively, run it manually: - - Follow the instructions from [Tauri prerequisites](https://tauri.app/v1/guides/getting-started/prerequisites) (including installing [Rust](https://rust-lang.org)). + - Follow the instructions from [Tauri prerequisites](https://v2.tauri.app/start/prerequisites/). - Install the Tauri CLI with `cd nightwatch/desktop && bun i`. - Launch via `bun run tauri dev`. diff --git a/nightwatch/auth/__init__.py b/nightwatch/auth/__init__.py index 2e99123..c3d0a91 100644 --- a/nightwatch/auth/__init__.py +++ b/nightwatch/auth/__init__.py @@ -46,10 +46,26 @@ def __init__(self) -> None: # Handle errors @app.exception_handler(RequestValidationError) async def validation_exception_handler(request: Request, exc: RequestValidationError) -> JSONResponse: - error = exc.errors()[0] # Just focus on one + error, message = exc.errors()[0], str(exc) # Just focus on one + match error["type"]: + case "too_short" | "string_too_short": + message = f"{error['loc'][1].capitalize()} should be at least {error['ctx']['min_length']} characters." + + case "too_long" | "string_too_long": + message = f"{error['loc'][1].capitalize()} should be {error['ctx']['max_length']} characters at most." + + case "missing": + message = f"{error['loc'][1].capitalize()} needs to be specified." + + case "string_type": + message = f"{error['loc'][1].capitalize()} must be a valid string." + + case "url_type" | "url_scheme" | "url_parsing": + message = f"{error['loc'][1].capitalize()} must be a valid URL." + return JSONResponse( status_code = 422, - content = {"code": 422, "data": error["msg"].lower().replace("value", error["loc"][1])} + content = {"code": 422, "data": message} ) # Routing diff --git a/nightwatch/client/__init__.py b/nightwatch/client/__init__.py index b0d6c74..a93b4cb 100644 --- a/nightwatch/client/__init__.py +++ b/nightwatch/client/__init__.py @@ -28,7 +28,7 @@ def connect_loop(host: str, port: int, username: str) -> None: ws = ORJSONWebSocket(ws) # Handle identification payload - ws.send({"type": "identify", "data": {"name": username, "color": config["user.color"]}}) + ws.send({"type": "identify", "data": {"name": username, "color": config["client.user_color"]}}) response = ws.recv() if response["type"] == "error": exit(f"\nCould not connect to {destination}. Additional details:\n{response['data']['text']}") diff --git a/nightwatch/desktop/src/css/main.css b/nightwatch/desktop/src/css/main.css index 5dcba08..7b68932 100644 --- a/nightwatch/desktop/src/css/main.css +++ b/nightwatch/desktop/src/css/main.css @@ -58,6 +58,11 @@ ul.nav { } /* Custom element colors based on Soda */ +#authServer { + cursor: pointer; + color: var(--sd-cherry-blossom-d25); +} + a.text-primary-emphasis { color: var(--sd-sky-d20) !important; } diff --git a/nightwatch/desktop/src/frames/auth/login.html b/nightwatch/desktop/src/frames/auth/login.html index 8209245..2685195 100644 --- a/nightwatch/desktop/src/frames/auth/login.html +++ b/nightwatch/desktop/src/frames/auth/login.html @@ -1,4 +1,4 @@ -
+
Account Login

@@ -8,7 +8,7 @@
Account Login
-
Logging in on: auth.iipython.dev.
+
Logging in on: .
@@ -16,11 +16,10 @@
Account Login
- +
- diff --git a/nightwatch/desktop/src/frames/auth/signup.html b/nightwatch/desktop/src/frames/auth/signup.html index 6bc3e22..36c67e1 100644 --- a/nightwatch/desktop/src/frames/auth/signup.html +++ b/nightwatch/desktop/src/frames/auth/signup.html @@ -1,4 +1,4 @@ -
+
Account Creation

@@ -8,7 +8,7 @@
Account Creation
-
Signing up on: auth.iipython.dev.
+
Signing up on: .
@@ -16,11 +16,10 @@
Account Creation
- +
- diff --git a/nightwatch/desktop/src/frames/errors/login.html b/nightwatch/desktop/src/frames/errors/login.html index 4005b16..97ad69d 100644 --- a/nightwatch/desktop/src/frames/errors/login.html +++ b/nightwatch/desktop/src/frames/errors/login.html @@ -6,12 +6,13 @@
Login Failure

The authentication server you use (${nightwatch.auth_server}) has failed to authenticate you.
- Error details: ${nightwatch._error.message}. + Error details: ${nightwatch._error.message}

- +
`; + delete nightwatch._error; })(); \ No newline at end of file diff --git a/nightwatch/desktop/src/frames/errors/network.html b/nightwatch/desktop/src/frames/errors/network.html index e75e69c..2c9020f 100644 --- a/nightwatch/desktop/src/frames/errors/network.html +++ b/nightwatch/desktop/src/frames/errors/network.html @@ -13,5 +13,6 @@
Network Failure
`; + delete nightwatch._error; })(); \ No newline at end of file diff --git a/nightwatch/desktop/src/frames/server/add.html b/nightwatch/desktop/src/frames/server/add.html index f1d4a5c..0fb76be 100644 --- a/nightwatch/desktop/src/frames/server/add.html +++ b/nightwatch/desktop/src/frames/server/add.html @@ -3,11 +3,11 @@
wss:// - +
- +
diff --git a/nightwatch/desktop/src/js/frames/auth.js b/nightwatch/desktop/src/js/frames/auth.js index 75bbfed..1ab5496 100644 --- a/nightwatch/desktop/src/js/frames/auth.js +++ b/nightwatch/desktop/src/js/frames/auth.js @@ -1,11 +1,22 @@ -document.querySelector("form").addEventListener("submit", async (e) => { - e.preventDefault(); +(() => { + const form = document.querySelector("form"), button = document.querySelector("button[type = submit]"); + form.addEventListener("submit", async (e) => { + e.preventDefault(); + button.innerHTML = `
`; - // Handle authentication - const response = await window.__NWAUTHMETHOD__( - document.getElementById("usernameInput").value, - document.getElementById("passwordInput").value - ) - if (response.code !== 200) return notifier.alert(response.data); - load_frame("welcome"); -}); \ No newline at end of file + // Handle authentication + const response = await nightwatch[form.getAttribute("data-nightwatch-action")]( + document.getElementById("usernameInput").value, + document.getElementById("passwordInput").value + ) + if (response.code !== 200) { + button.innerText = "Continue"; + return notifier.alert(response.data); + } + + await nightwatch.authenticate(); + if (nightwatch._error) return load_frame(`errors/${nightwatch._error.type}`); + load_frame("welcome"); + }); + document.getElementById("authServer").innerText = nightwatch.auth_server; +})(); diff --git a/nightwatch/desktop/src/js/nightwatch.js b/nightwatch/desktop/src/js/nightwatch.js index 1dcf8b2..a128fcd 100644 --- a/nightwatch/desktop/src/js/nightwatch.js +++ b/nightwatch/desktop/src/js/nightwatch.js @@ -9,9 +9,20 @@ class Nightwatch { this.auth_server = "auth.iipython.dev"; } + // Handle servers + async add_server(server) { + try { + const info = await (await fetch(`https://${server.replace("/gateway", "/info")}`)).json(); + return { + type: "success", + data: info + } + } catch (error) { return { type: "fail" }; } + } + // Handle local authentication async authenticate() { - this.token = localStorage.getItem("token"); + this.token = this.token || localStorage.getItem("token"); if (!this.token) return; // Fetch user data diff --git a/nightwatch/server/__init__.py b/nightwatch/server/__init__.py index 0b74897..539355f 100644 --- a/nightwatch/server/__init__.py +++ b/nightwatch/server/__init__.py @@ -1,13 +1,17 @@ # Copyright (c) 2024 iiPython # Modules -from http import HTTPStatus - +import anyio import orjson from pydantic import ValidationError -from websockets import WebSocketCommonProtocol, Headers -from websockets.exceptions import ConnectionClosedError +from starlette.requests import Request +from starlette.routing import Route, WebSocketRoute +from starlette.responses import JSONResponse, PlainTextResponse +from starlette.websockets import WebSocket +from starlette.applications import Starlette + +from .utils import broadcast from .utils.commands import registry from .utils.constant import Constant from .utils.websocket import NightwatchClient @@ -19,31 +23,42 @@ class NightwatchStateManager(): def __init__(self) -> None: self.clients = {} - def add_client(self, client: WebSocketCommonProtocol) -> None: + def add_client(self, client: WebSocket) -> None: self.clients[client] = None - def remove_client(self, client: WebSocketCommonProtocol) -> None: + def remove_client(self, client: WebSocket) -> None: if client in self.clients: del self.clients[client] state = NightwatchStateManager() # Handle API -async def process_api(path: str, request_headers: Headers) -> tuple[HTTPStatus, list, bytes]: - if path == "/info": - return HTTPStatus.OK, [], orjson.dumps({ - "name": Constant.SERVER_NAME, - "version": Constant.SERVER_VERSION, - "icon": Constant.SERVER_ICON - }) +async def route_home(request: Request) -> PlainTextResponse: + return PlainTextResponse("Nightwatch is running.") - return HTTPStatus.NOT_FOUND, [], b"Not Found\n" +async def route_info(request: Request) -> JSONResponse: + return JSONResponse({ + "name": Constant.SERVER_NAME, + "version": Constant.SERVER_VERSION, + "icon": Constant.SERVER_ICON + }) # Socket entrypoint -async def connection(websocket: WebSocketCommonProtocol) -> None: +async def route_gateway(websocket: WebSocket) -> None: + await websocket.accept() + + client = NightwatchClient(state, websocket) + async with anyio.create_task_group() as task_group: + async def run_chatroom_ws_receiver() -> None: + await chatroom_ws_receiver(websocket, client) + task_group.cancel_scope.cancel() + + task_group.start_soon(run_chatroom_ws_receiver) + await chatroom_ws_sender(websocket) + +async def chatroom_ws_receiver(websocket: WebSocket, client: NightwatchClient) -> None: try: - client = NightwatchClient(state, websocket) - async for message in websocket: + async for message in websocket.iter_text(): message = orjson.loads(message) if message.get("type") not in registry.commands: await client.send("error", text = "Specified command type does not exist or is missing.") @@ -67,7 +82,20 @@ async def connection(websocket: WebSocketCommonProtocol) -> None: except orjson.JSONDecodeError: log.warn("ws", "Failed to decode JSON from client.") - except ConnectionClosedError: - log.info("ws", "Client disconnected.") - state.remove_client(websocket) + log.info("ws", "Client disconnected.") + +async def chatroom_ws_sender(websocket: WebSocket) -> None: + async with broadcast.subscribe("general") as sub: + async for event in sub: # type: ignore + await websocket.send_text(event.message) + +app = Starlette( + routes = [ + Route("/", route_home), + Route("/info", route_info), + WebSocketRoute("/gateway", route_gateway) + ], + on_startup = [broadcast.connect], + on_shutdown = [broadcast.disconnect] +) diff --git a/nightwatch/server/__main__.py b/nightwatch/server/__main__.py index 748744d..a4fa83c 100644 --- a/nightwatch/server/__main__.py +++ b/nightwatch/server/__main__.py @@ -2,21 +2,19 @@ # Modules import os -import asyncio -from websockets.server import serve +import uvicorn -from . import connection, process_api +from . import app from nightwatch import __version__ from nightwatch.logging import log # Entrypoint -async def main() -> None: +def main() -> None: host, port = os.getenv("HOST", "localhost"), int(os.getenv("PORT", 8000)) log.info("ws", f"Nightwatch v{__version__} running on ws://{host}:{port}/") - async with serve(connection, host, port, process_request = process_api): - await asyncio.Future() + uvicorn.run(app, host = host, port = port, log_level = "info") if __name__ == "__main__": - asyncio.run(main()) + main() diff --git a/nightwatch/server/utils/__init__.py b/nightwatch/server/utils/__init__.py new file mode 100644 index 0000000..1df7d52 --- /dev/null +++ b/nightwatch/server/utils/__init__.py @@ -0,0 +1,7 @@ +# Copyright (c) 2024 iiPython + +# Modules +from broadcaster import Broadcast + +# Setup broadcast connection +broadcast = Broadcast("memory://") diff --git a/nightwatch/server/utils/commands.py b/nightwatch/server/utils/commands.py index a0aabf7..03a2366 100644 --- a/nightwatch/server/utils/commands.py +++ b/nightwatch/server/utils/commands.py @@ -4,9 +4,8 @@ from typing import Callable import orjson -import websockets -from . import models +from . import models, broadcast from .constant import Constant from .websocket import NightwatchClient @@ -42,7 +41,7 @@ async def command_identify(state, client: NightwatchClient, data: models.Identif client.identified = True await client.send("server", name = Constant.SERVER_NAME, online = len(state.clients)) - websockets.broadcast(state.clients, orjson.dumps({ + await broadcast.publish("general", orjson.dumps({ "type": "message", "data": {"text": f"{data.name} joined the chatroom.", "user": Constant.SERVER_USER} }).decode()) @@ -52,7 +51,7 @@ async def command_message(state, client: NightwatchClient, data: models.MessageM if not client.identified: return await client.send("error", text = "You must identify before sending a message.") - websockets.broadcast(state.clients, orjson.dumps({ + await broadcast.publish("general", orjson.dumps({ "type": "message", "data": {"text": data.text, "user": client.user_data} }).decode()) diff --git a/nightwatch/server/utils/websocket.py b/nightwatch/server/utils/websocket.py index aa7f845..da7e19d 100644 --- a/nightwatch/server/utils/websocket.py +++ b/nightwatch/server/utils/websocket.py @@ -4,12 +4,12 @@ from typing import Any import orjson -from websockets import WebSocketCommonProtocol +from starlette.websockets import WebSocket class NightwatchClient(): - """This class acts as a wrapper on top of WebSocketCommonProtocol that implements + """This class acts as a wrapper on top of WebSocket that implements data serialization through orjson.""" - def __init__(self, state, client: WebSocketCommonProtocol) -> None: + def __init__(self, state, client: WebSocket) -> None: self.client = client self.identified, self.callback = False, None @@ -22,7 +22,7 @@ async def send(self, message_type: str, **message_data) -> None: payload["callback"] = self.callback self.callback = None - await self.client.send(orjson.dumps(payload).decode()) + await self.client.send_text(orjson.dumps(payload).decode()) def set_callback(self, callback: str) -> None: self.callback = callback diff --git a/pyproject.toml b/pyproject.toml index 53f87d1..11034f0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,9 +35,12 @@ dependencies = [ [project.optional-dependencies] serve = [ + "anyio", + "uvloop", "fastapi", "pymongo", "pydantic", + "broadcaster", "pydantic-extra-types", "starlette", "argon2-cffi",