231 lines
7.7 KiB
Python
231 lines
7.7 KiB
Python
import json
|
|
from typing import Any, Dict, Optional
|
|
|
|
import jsonschema
|
|
import lz4.frame
|
|
from channels.generic.websocket import AsyncWebsocketConsumer
|
|
from django.conf import settings
|
|
from websockets.exceptions import ConnectionClosed
|
|
|
|
from .stats import WebsocketThroughputLogger
|
|
|
|
|
|
# Custom Websocket error codes (not to be confused with the websocket *connection*
|
|
# status codes like 1000 or 1006. These are custom ones for OpenSlides to give a
|
|
# machine parseable response, so the client can react on errors.
|
|
|
|
WEBSOCKET_NOT_AUTHORIZED = 100
|
|
# E.g. if a user does not have the right permission(s) for a message.
|
|
|
|
WEBSOCKET_CHANGE_ID_TOO_HIGH = 101
|
|
# If data is requested and the given change id is higher than the highest change id
|
|
# from the element_cache.
|
|
|
|
WEBSOCKET_WRONG_FORMAT = 102
|
|
# If the recieved data has not the expected format.
|
|
|
|
|
|
class AsyncCompressedJsonWebsocketConsumer(AsyncWebsocketConsumer):
|
|
async def receive(
|
|
self,
|
|
text_data: Optional[str] = None,
|
|
bytes_data: Optional[bytes] = None,
|
|
**kwargs: Dict[str, Any],
|
|
) -> None:
|
|
if bytes_data:
|
|
uncompressed_data = lz4.frame.decompress(bytes_data)
|
|
text_data = uncompressed_data.decode("utf-8")
|
|
|
|
recv_len = len(bytes_data)
|
|
uncompressed_len = len(uncompressed_data)
|
|
await WebsocketThroughputLogger.receive(uncompressed_len, recv_len)
|
|
elif text_data:
|
|
uncompressed_len = len(text_data.encode("utf-8"))
|
|
await WebsocketThroughputLogger.receive(uncompressed_len)
|
|
|
|
if text_data:
|
|
await self.receive_json(json.loads(text_data), **kwargs)
|
|
|
|
async def send_json(self, content: Any, close: bool = False) -> None:
|
|
text_data = json.dumps(content)
|
|
bytes_data = None # type: ignore
|
|
|
|
b_text_data = text_data.encode("utf-8")
|
|
uncompressed_len = len(b_text_data)
|
|
|
|
if getattr(settings, "COMPRESSION", True):
|
|
compressed_data = lz4.frame.compress(b_text_data)
|
|
ratio = len(b_text_data) / len(compressed_data)
|
|
if ratio > 1:
|
|
bytes_data = compressed_data
|
|
text_data = None # type: ignore
|
|
await WebsocketThroughputLogger.send(uncompressed_len, len(bytes_data))
|
|
|
|
if not bytes_data:
|
|
await WebsocketThroughputLogger.send(uncompressed_len)
|
|
|
|
await self.send(text_data=text_data, bytes_data=bytes_data, close=close)
|
|
|
|
async def receive_json(self, content: str, **kwargs: Dict[str, Any]) -> None:
|
|
pass
|
|
|
|
|
|
class ProtocollAsyncJsonWebsocketConsumer(AsyncCompressedJsonWebsocketConsumer):
|
|
"""
|
|
Mixin for JSONWebsocketConsumers, that speaks the a special protocol.
|
|
"""
|
|
|
|
async def send_json( # type: ignore
|
|
self,
|
|
type: str,
|
|
content: Any,
|
|
id: Optional[str] = None,
|
|
in_response: Optional[str] = None,
|
|
silence_errors: Optional[bool] = True,
|
|
) -> None:
|
|
"""
|
|
Sends the data with the type.
|
|
If silence_errors is True (default), all ConnectionClosed
|
|
and runtime errors during sending will be ignored.
|
|
"""
|
|
out = {"type": type, "content": content}
|
|
if id:
|
|
out["id"] = id
|
|
if in_response:
|
|
out["in_response"] = in_response
|
|
try:
|
|
await super().send_json(out)
|
|
except (ConnectionClosed, RuntimeError) as e:
|
|
# The ConnectionClosed error is thrown by the websocket lib: websocket/protocol.py in ensure_open
|
|
# `websockets.exceptions.ConnectionClosed: WebSocket connection is closed: code = 1005
|
|
# (no status code [internal]), no reason` (Also with other codes)
|
|
# The RuntimeError is thrown by uvicorn: uvicorn/protocols/websockets/websockets_impl.py in asgi_send
|
|
# `RuntimeError: Unexpected ASGI message 'websocket.send', after sending 'websocket.close'`
|
|
if not silence_errors:
|
|
raise e
|
|
|
|
async def send_error(
|
|
self,
|
|
code: int,
|
|
message: str,
|
|
in_response: Optional[str] = None,
|
|
silence_errors: Optional[bool] = True,
|
|
) -> None:
|
|
"""
|
|
Send generic error messages with a custom status code (see above) and a text message.
|
|
"""
|
|
await self.send_json(
|
|
"error",
|
|
{"code": code, "message": message},
|
|
None,
|
|
in_response=in_response,
|
|
silence_errors=silence_errors,
|
|
)
|
|
|
|
async def receive_json(self, content: Any) -> None: # type: ignore
|
|
"""
|
|
Receives the json data, parses it and calls receive_content.
|
|
"""
|
|
try:
|
|
jsonschema.validate(content, schema)
|
|
except jsonschema.ValidationError as err:
|
|
try:
|
|
in_response = content["id"]
|
|
except (TypeError, KeyError):
|
|
# content is not a dict (TypeError) or has not the key id (KeyError)
|
|
in_response = None
|
|
|
|
await self.send_error(
|
|
code=WEBSOCKET_WRONG_FORMAT, message=str(err), in_response=in_response
|
|
)
|
|
return
|
|
|
|
await websocket_client_messages[content["type"]].receive_content(
|
|
self, content["content"], id=content["id"]
|
|
)
|
|
|
|
|
|
schema: Dict[str, Any] = {
|
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
"title": "OpenSlidesWebsocketProtocol",
|
|
"description": "The packages that OpenSlides sends between the server and the client.",
|
|
"type": "object",
|
|
"properties": {
|
|
"type": {
|
|
"description": "Defines what kind of packages is packed.",
|
|
"type": "string",
|
|
},
|
|
"content": {"description": "The content of the package."},
|
|
"id": {"description": "An identifier of the package.", "type": "string"},
|
|
"in_response": {
|
|
"description": "The id of another package that the other part sent before.",
|
|
"type": "string",
|
|
},
|
|
},
|
|
"required": ["type", "content", "id"],
|
|
"anyOf": [], # This will be filled in register_client_message()
|
|
}
|
|
|
|
|
|
class BaseWebsocketClientMessage:
|
|
schema: Dict[str, object] = {}
|
|
"""
|
|
Optional schema.
|
|
|
|
If schema is not set, any value in content is accepted.
|
|
"""
|
|
|
|
identifier: str = ""
|
|
"""
|
|
A unique identifier for the websocket message.
|
|
|
|
This is used as value in the 'type' property in the websocket message.
|
|
"""
|
|
|
|
content_required = True
|
|
"""
|
|
Desiedes, if the content property is required.
|
|
"""
|
|
|
|
async def receive_content(
|
|
self, consumer: "ProtocollAsyncJsonWebsocketConsumer", message: Any, id: str
|
|
) -> None:
|
|
raise NotImplementedError(
|
|
"WebsocketClientMessage needs the method receive_content()."
|
|
)
|
|
|
|
|
|
websocket_client_messages: Dict[str, BaseWebsocketClientMessage] = {}
|
|
"""
|
|
Saves all websocket client message object ordered by there identifier.
|
|
"""
|
|
|
|
|
|
def register_client_message(
|
|
websocket_client_message: BaseWebsocketClientMessage,
|
|
) -> None:
|
|
"""
|
|
Registers one websocket client message class.
|
|
"""
|
|
if (
|
|
not websocket_client_message.identifier
|
|
or websocket_client_message.identifier in websocket_client_messages
|
|
):
|
|
raise NotImplementedError("WebsocketClientMessage needs a unique identifier.")
|
|
|
|
websocket_client_messages[
|
|
websocket_client_message.identifier
|
|
] = websocket_client_message
|
|
|
|
# Add the message schema to the schema
|
|
message_schema: Dict[str, Any] = {
|
|
"properties": {
|
|
"type": {"const": websocket_client_message.identifier},
|
|
"content": websocket_client_message.schema,
|
|
}
|
|
}
|
|
if websocket_client_message.content_required:
|
|
message_schema["required"] = ["content"]
|
|
|
|
schema["anyOf"].append(message_schema)
|