django-socket 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- django_socket/__init__.py +60 -0
- django_socket/apps.py +32 -0
- django_socket/asgi.py +62 -0
- django_socket/auth.py +81 -0
- django_socket/checks.py +75 -0
- django_socket/dispatch.py +191 -0
- django_socket/events.py +143 -0
- django_socket/groups.py +287 -0
- django_socket/management/__init__.py +0 -0
- django_socket/management/commands/__init__.py +0 -0
- django_socket/management/commands/runserver.py +81 -0
- django_socket/management/commands/ws.py +64 -0
- django_socket/patch.py +54 -0
- django_socket/routing.py +120 -0
- django_socket/static/django_socket/client.js +262 -0
- django_socket/templatetags/__init__.py +0 -0
- django_socket/templatetags/django_socket.py +28 -0
- django_socket/testing.py +330 -0
- django_socket/websocket.py +587 -0
- django_socket-0.1.0.dist-info/METADATA +1008 -0
- django_socket-0.1.0.dist-info/RECORD +24 -0
- django_socket-0.1.0.dist-info/WHEEL +5 -0
- django_socket-0.1.0.dist-info/licenses/LICENSE +21 -0
- django_socket-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""
|
|
2
|
+
django_socket -- WebSockets en Django sin ceremonia.
|
|
3
|
+
|
|
4
|
+
Instalar es: `pip install django-socket` y añadirlo a INSTALLED_APPS. Ya esta.
|
|
5
|
+
|
|
6
|
+
# miapp/sockets.py
|
|
7
|
+
from django_socket import ws
|
|
8
|
+
|
|
9
|
+
@ws("chat/<str:room>/", group="room:{room}")
|
|
10
|
+
async def chat(sock, room):
|
|
11
|
+
async for msg in sock:
|
|
12
|
+
await sock.broadcast({"de": str(sock.user), "texto": msg.text})
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from .asgi import ASGIApplication
|
|
16
|
+
from .auth import login_required
|
|
17
|
+
from .events import Events
|
|
18
|
+
from .groups import (
|
|
19
|
+
BaseLayer,
|
|
20
|
+
MemoryLayer,
|
|
21
|
+
RedisLayer,
|
|
22
|
+
broadcast,
|
|
23
|
+
broadcast_sync,
|
|
24
|
+
group_size,
|
|
25
|
+
set_layer,
|
|
26
|
+
)
|
|
27
|
+
from .routing import get_routes, ws
|
|
28
|
+
from .websocket import (
|
|
29
|
+
InvalidJSON,
|
|
30
|
+
Message,
|
|
31
|
+
WebSocket,
|
|
32
|
+
WebSocketClosed,
|
|
33
|
+
WebSocketDisconnect,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
__version__ = "0.1.0"
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
# Lo que usaras el 99% del tiempo
|
|
40
|
+
"ws",
|
|
41
|
+
"Events",
|
|
42
|
+
"broadcast",
|
|
43
|
+
"broadcast_sync",
|
|
44
|
+
"login_required",
|
|
45
|
+
# Tipos, para anotar
|
|
46
|
+
"WebSocket",
|
|
47
|
+
"Message",
|
|
48
|
+
"WebSocketDisconnect",
|
|
49
|
+
"WebSocketClosed",
|
|
50
|
+
"InvalidJSON",
|
|
51
|
+
# Puntos de extension
|
|
52
|
+
"ASGIApplication",
|
|
53
|
+
"BaseLayer",
|
|
54
|
+
"MemoryLayer",
|
|
55
|
+
"RedisLayer",
|
|
56
|
+
"set_layer",
|
|
57
|
+
"group_size",
|
|
58
|
+
"get_routes",
|
|
59
|
+
"__version__",
|
|
60
|
+
]
|
django_socket/apps.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
|
|
3
|
+
from django.apps import AppConfig
|
|
4
|
+
|
|
5
|
+
logger = logging.getLogger("django_socket")
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class DjangoSocketConfig(AppConfig):
|
|
9
|
+
name = "django_socket"
|
|
10
|
+
label = "django_socket"
|
|
11
|
+
verbose_name = "Django Socket"
|
|
12
|
+
|
|
13
|
+
def ready(self):
|
|
14
|
+
from django.conf import settings
|
|
15
|
+
from django.utils.module_loading import autodiscover_modules
|
|
16
|
+
|
|
17
|
+
from . import checks # noqa: F401 (se registran al importarse)
|
|
18
|
+
from . import patch, routing
|
|
19
|
+
|
|
20
|
+
# Importa <cada_app>/sockets.py, igual que el admin con admin.py.
|
|
21
|
+
autodiscover_modules("sockets")
|
|
22
|
+
|
|
23
|
+
conf = getattr(settings, "DJANGO_SOCKET", {}) or {}
|
|
24
|
+
if conf.get("PATCH_ASGI", True):
|
|
25
|
+
patch.install()
|
|
26
|
+
|
|
27
|
+
found = routing.get_routes()
|
|
28
|
+
logger.debug(
|
|
29
|
+
"django_socket: %d ruta(s): %s",
|
|
30
|
+
len(found),
|
|
31
|
+
", ".join(f"/{r.route}" for r in found) or "(ninguna)",
|
|
32
|
+
)
|
django_socket/asgi.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Punto de entrada ASGI explicito.
|
|
2
|
+
|
|
3
|
+
Normalmente **no necesitas nada de aqui**: basta con añadir "django_socket" a
|
|
4
|
+
INSTALLED_APPS y el `asgi.py` que genero `startproject` sirve WebSockets tal
|
|
5
|
+
cual (ver `patch.py`).
|
|
6
|
+
|
|
7
|
+
`ASGIApplication` existe para quien prefiere declararlo a mano, para componer
|
|
8
|
+
con otro middleware ASGI, o para quien puso PATCH_ASGI=False.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from . import dispatch
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ASGIApplication:
|
|
17
|
+
"""
|
|
18
|
+
Uso explicito en tu `asgi.py`:
|
|
19
|
+
|
|
20
|
+
from django_socket import ASGIApplication
|
|
21
|
+
application = ASGIApplication()
|
|
22
|
+
|
|
23
|
+
El trafico HTTP va a Django sin tocarse; solo se intercepta el scope
|
|
24
|
+
'websocket' (y 'lifespan', para arrancar y parar la capa de difusion).
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(self, http_app=None):
|
|
28
|
+
if http_app is None:
|
|
29
|
+
from django.core.asgi import get_asgi_application
|
|
30
|
+
|
|
31
|
+
http_app = _wrap_static(get_asgi_application()) # hace django.setup()
|
|
32
|
+
self.http_app = http_app
|
|
33
|
+
|
|
34
|
+
async def __call__(self, scope, receive, send):
|
|
35
|
+
kind = scope["type"]
|
|
36
|
+
if kind == "websocket":
|
|
37
|
+
return await dispatch.handle_websocket(scope, receive, send)
|
|
38
|
+
if kind == "lifespan":
|
|
39
|
+
return await dispatch.handle_lifespan(scope, receive, send)
|
|
40
|
+
return await self.http_app(scope, receive, send)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def factory():
|
|
44
|
+
"""
|
|
45
|
+
App ASGI construida al vuelo, sin que el proyecto declare nada.
|
|
46
|
+
|
|
47
|
+
La usa `runserver` cuando no hay ASGI_APPLICATION en settings, para que la
|
|
48
|
+
libreria funcione recien instalada.
|
|
49
|
+
"""
|
|
50
|
+
return ASGIApplication()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _wrap_static(app):
|
|
54
|
+
"""En DEBUG sirve /static/ igual que hace `runserver`, sin tocar websockets."""
|
|
55
|
+
from django.apps import apps
|
|
56
|
+
from django.conf import settings
|
|
57
|
+
|
|
58
|
+
if not settings.DEBUG or not apps.is_installed("django.contrib.staticfiles"):
|
|
59
|
+
return app
|
|
60
|
+
from django.contrib.staticfiles.handlers import ASGIStaticFilesHandler
|
|
61
|
+
|
|
62
|
+
return ASGIStaticFilesHandler(app)
|
django_socket/auth.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Sesion y usuario de Django a partir de las cookies del handshake.
|
|
2
|
+
|
|
3
|
+
Todo es async de verdad: Django 5+ expone `aget_user` y `SessionStore.aget`,
|
|
4
|
+
asi que no hace falta pasar por un thread pool.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
from importlib import import_module
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger("django_socket")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class _SessionCarrier:
|
|
16
|
+
"""Lo minimo que `django.contrib.auth.aget_user` espera de un request."""
|
|
17
|
+
|
|
18
|
+
__slots__ = ("session",)
|
|
19
|
+
|
|
20
|
+
def __init__(self, session):
|
|
21
|
+
self.session = session
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _auth_installed() -> bool:
|
|
25
|
+
from django.apps import apps
|
|
26
|
+
|
|
27
|
+
return apps.is_installed("django.contrib.auth") and apps.is_installed(
|
|
28
|
+
"django.contrib.sessions"
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
async def resolve(sock) -> None:
|
|
33
|
+
"""Rellena `sock.session` y `sock.user`. Nunca lanza."""
|
|
34
|
+
from django.conf import settings
|
|
35
|
+
|
|
36
|
+
if not _auth_installed():
|
|
37
|
+
return
|
|
38
|
+
|
|
39
|
+
engine = import_module(settings.SESSION_ENGINE)
|
|
40
|
+
session_key = sock.cookies.get(settings.SESSION_COOKIE_NAME)
|
|
41
|
+
session = engine.SessionStore(session_key)
|
|
42
|
+
sock.session = session
|
|
43
|
+
|
|
44
|
+
carrier = _SessionCarrier(session)
|
|
45
|
+
try:
|
|
46
|
+
try:
|
|
47
|
+
from django.contrib.auth import aget_user
|
|
48
|
+
except ImportError:
|
|
49
|
+
# Django < 5.0 no tiene ni aget_user ni SessionStore.aget: al hilo.
|
|
50
|
+
from asgiref.sync import sync_to_async
|
|
51
|
+
from django.contrib.auth import get_user
|
|
52
|
+
|
|
53
|
+
sock.user = await sync_to_async(get_user)(carrier)
|
|
54
|
+
else:
|
|
55
|
+
sock.user = await aget_user(carrier)
|
|
56
|
+
except Exception:
|
|
57
|
+
logger.exception("django_socket: fallo al resolver el usuario")
|
|
58
|
+
from django.contrib.auth.models import AnonymousUser
|
|
59
|
+
|
|
60
|
+
sock.user = AnonymousUser()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def login_required(handler):
|
|
64
|
+
"""
|
|
65
|
+
Cierra la conexion con 4401 si el usuario no esta autenticado.
|
|
66
|
+
|
|
67
|
+
@ws("panel/")
|
|
68
|
+
@login_required
|
|
69
|
+
async def panel(sock): ...
|
|
70
|
+
"""
|
|
71
|
+
import functools
|
|
72
|
+
|
|
73
|
+
@functools.wraps(handler)
|
|
74
|
+
async def wrapper(sock, *args, **kwargs):
|
|
75
|
+
user = getattr(sock, "user", None)
|
|
76
|
+
if user is None or not user.is_authenticated:
|
|
77
|
+
await sock.close(4401, "Authentication required")
|
|
78
|
+
return
|
|
79
|
+
return await handler(sock, *args, **kwargs)
|
|
80
|
+
|
|
81
|
+
return wrapper
|
django_socket/checks.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Avisos via `manage.py check`, para que los fallos de integracion salgan
|
|
2
|
+
antes de desplegar y no como un socket que calla.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from django.core.checks import Error, Warning, register
|
|
8
|
+
|
|
9
|
+
W001 = "django_socket.W001" # capa memory con varios workers
|
|
10
|
+
W002 = "django_socket.W002" # ninguna ruta registrada
|
|
11
|
+
W003 = "django_socket.W003" # origenes abiertos a todo
|
|
12
|
+
E001 = "django_socket.E001" # opcion desconocida en DJANGO_SOCKET
|
|
13
|
+
|
|
14
|
+
KNOWN_KEYS = {
|
|
15
|
+
"LAYER",
|
|
16
|
+
"REDIS_URL",
|
|
17
|
+
"PREFIX",
|
|
18
|
+
"ALLOWED_ORIGINS",
|
|
19
|
+
"REQUIRE_ORIGIN",
|
|
20
|
+
"PATCH_ASGI",
|
|
21
|
+
"SEND_QUEUE_MAX",
|
|
22
|
+
"SEND_QUEUE_FULL",
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@register()
|
|
27
|
+
def check_settings(app_configs, **kwargs):
|
|
28
|
+
from django.conf import settings
|
|
29
|
+
|
|
30
|
+
problems = []
|
|
31
|
+
conf = getattr(settings, "DJANGO_SOCKET", {}) or {}
|
|
32
|
+
|
|
33
|
+
unknown = set(conf) - KNOWN_KEYS
|
|
34
|
+
if unknown:
|
|
35
|
+
problems.append(
|
|
36
|
+
Error(
|
|
37
|
+
f"Opcion(es) desconocida(s) en DJANGO_SOCKET: {sorted(unknown)}.",
|
|
38
|
+
hint=f"Las validas son: {sorted(KNOWN_KEYS)}.",
|
|
39
|
+
id=E001,
|
|
40
|
+
)
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
allowed = conf.get("ALLOWED_ORIGINS")
|
|
44
|
+
if allowed and "*" in allowed and not settings.DEBUG:
|
|
45
|
+
problems.append(
|
|
46
|
+
Warning(
|
|
47
|
+
"DJANGO_SOCKET['ALLOWED_ORIGINS'] contiene '*' con DEBUG=False.",
|
|
48
|
+
hint=(
|
|
49
|
+
"Cualquier web podra abrir un socket contra la tuya con las "
|
|
50
|
+
"cookies de sesion de tus usuarios (cross-site WebSocket "
|
|
51
|
+
"hijacking). Enumera los origenes que confias."
|
|
52
|
+
),
|
|
53
|
+
id=W003,
|
|
54
|
+
)
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
return problems
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@register()
|
|
61
|
+
def check_routes(app_configs, **kwargs):
|
|
62
|
+
from . import routing
|
|
63
|
+
|
|
64
|
+
if routing.get_routes():
|
|
65
|
+
return []
|
|
66
|
+
return [
|
|
67
|
+
Warning(
|
|
68
|
+
"django_socket esta instalado pero no hay ninguna ruta websocket.",
|
|
69
|
+
hint=(
|
|
70
|
+
"Crea <tu_app>/sockets.py y decora un 'async def' con @ws('...'). "
|
|
71
|
+
"Se autodescubre igual que admin.py."
|
|
72
|
+
),
|
|
73
|
+
id=W002,
|
|
74
|
+
)
|
|
75
|
+
]
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""El nucleo: atiende los scopes 'websocket' y 'lifespan'.
|
|
2
|
+
|
|
3
|
+
Vive aparte de `asgi.py` porque hay dos caminos que llegan aqui: el parche
|
|
4
|
+
sobre `ASGIHandler` (modo cero-configuracion) y `ASGIApplication` explicito.
|
|
5
|
+
Los dos comparten este codigo.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
from urllib.parse import urlparse
|
|
12
|
+
|
|
13
|
+
from asgiref.sync import ThreadSensitiveContext
|
|
14
|
+
|
|
15
|
+
from . import auth as auth_mod
|
|
16
|
+
from . import groups, routing
|
|
17
|
+
from .websocket import InvalidJSON, WebSocket, WebSocketDisconnect
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger("django_socket")
|
|
20
|
+
|
|
21
|
+
# Codigos de cierre propios (rango privado 4000-4999).
|
|
22
|
+
CLOSE_NO_ROUTE = 4404
|
|
23
|
+
CLOSE_BAD_DATA = 4400 # el cliente mando algo que no se puede parsear
|
|
24
|
+
CLOSE_SERVER_ERROR = 1011
|
|
25
|
+
|
|
26
|
+
_layer_started = False
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _settings():
|
|
30
|
+
from django.conf import settings
|
|
31
|
+
|
|
32
|
+
return getattr(settings, "DJANGO_SOCKET", {}) or {}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
async def _start_layer() -> None:
|
|
36
|
+
global _layer_started
|
|
37
|
+
if not _layer_started:
|
|
38
|
+
await groups.get_layer().startup()
|
|
39
|
+
_layer_started = True
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
async def _stop_layer() -> None:
|
|
43
|
+
global _layer_started
|
|
44
|
+
if _layer_started:
|
|
45
|
+
await groups.get_layer().shutdown()
|
|
46
|
+
_layer_started = False
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# ---------------------------------------------------------------- lifespan
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
async def handle_lifespan(scope, receive, send) -> None:
|
|
53
|
+
while True:
|
|
54
|
+
message = await receive()
|
|
55
|
+
if message["type"] == "lifespan.startup":
|
|
56
|
+
try:
|
|
57
|
+
await _start_layer()
|
|
58
|
+
except Exception as exc:
|
|
59
|
+
logger.exception("django_socket: fallo en el arranque")
|
|
60
|
+
await send({"type": "lifespan.startup.failed", "message": str(exc)})
|
|
61
|
+
return
|
|
62
|
+
await send({"type": "lifespan.startup.complete"})
|
|
63
|
+
elif message["type"] == "lifespan.shutdown":
|
|
64
|
+
try:
|
|
65
|
+
await _stop_layer()
|
|
66
|
+
finally:
|
|
67
|
+
await send({"type": "lifespan.shutdown.complete"})
|
|
68
|
+
return
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# --------------------------------------------------------------- websocket
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
async def handle_websocket(scope, receive, send) -> None:
|
|
75
|
+
# El primer evento del protocolo siempre es websocket.connect.
|
|
76
|
+
event = await receive()
|
|
77
|
+
if event["type"] != "websocket.connect":
|
|
78
|
+
return
|
|
79
|
+
|
|
80
|
+
await _start_layer() # por si el servidor no soporta lifespan
|
|
81
|
+
sock = WebSocket(scope, receive, send, layer=groups.get_layer())
|
|
82
|
+
|
|
83
|
+
if not origin_allowed(sock):
|
|
84
|
+
logger.warning(
|
|
85
|
+
"django_socket: origen rechazado %r para %s",
|
|
86
|
+
sock.headers.get("origin"),
|
|
87
|
+
sock.path,
|
|
88
|
+
)
|
|
89
|
+
# En seco: un origen ajeno no debe tener un socket abierto ni un instante.
|
|
90
|
+
await sock.deny()
|
|
91
|
+
return
|
|
92
|
+
|
|
93
|
+
match = routing.resolve(sock.path)
|
|
94
|
+
if match is None:
|
|
95
|
+
logger.warning(
|
|
96
|
+
"django_socket: ninguna ruta casa con %s. Registradas: %s",
|
|
97
|
+
sock.path,
|
|
98
|
+
", ".join(f"/{r.route}" for r in routing.get_routes()) or "(ninguna)",
|
|
99
|
+
)
|
|
100
|
+
await sock.close(CLOSE_NO_ROUTE, "No route")
|
|
101
|
+
return
|
|
102
|
+
|
|
103
|
+
route, kwargs = match
|
|
104
|
+
sock.path_params = kwargs
|
|
105
|
+
|
|
106
|
+
async with ThreadSensitiveContext():
|
|
107
|
+
if route.auth:
|
|
108
|
+
await auth_mod.resolve(sock)
|
|
109
|
+
|
|
110
|
+
try:
|
|
111
|
+
if route.group:
|
|
112
|
+
# group="room:{room}" se rellena con los parametros de la ruta.
|
|
113
|
+
await sock.join(route.group.format(**kwargs))
|
|
114
|
+
await route.handler(sock, **kwargs)
|
|
115
|
+
except WebSocketDisconnect:
|
|
116
|
+
pass # el cliente se fue; salida normal
|
|
117
|
+
except InvalidJSON as exc:
|
|
118
|
+
# Culpa del cliente, no del servidor: un aviso y un codigo que lo
|
|
119
|
+
# diga. Nada de traceback ni de 1011, que harian pensar que el bug
|
|
120
|
+
# es tuyo cada vez que alguien mande basura por el socket.
|
|
121
|
+
logger.warning(
|
|
122
|
+
"django_socket: %s en %s (cliente %s)", exc, sock.path, sock.client
|
|
123
|
+
)
|
|
124
|
+
await sock.close(CLOSE_BAD_DATA, "Invalid JSON")
|
|
125
|
+
return
|
|
126
|
+
except Exception:
|
|
127
|
+
logger.exception("django_socket: excepcion en el handler de %s", sock.path)
|
|
128
|
+
await sock.close(CLOSE_SERVER_ERROR, "Internal error")
|
|
129
|
+
return
|
|
130
|
+
await sock.close()
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
# ------------------------------------------------------------------ origen
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def origin_allowed(sock) -> bool:
|
|
137
|
+
"""
|
|
138
|
+
Los WebSockets no estan sujetos a la politica de mismo origen: sin esta
|
|
139
|
+
comprobacion cualquier web podria abrir un socket autenticado contra la
|
|
140
|
+
tuya (cross-site WebSocket hijacking).
|
|
141
|
+
|
|
142
|
+
Un Origin ausente se acepta: los navegadores siempre lo mandan, asi que
|
|
143
|
+
solo lo omiten clientes nativos. Ponlo estricto con REQUIRE_ORIGIN.
|
|
144
|
+
"""
|
|
145
|
+
from django.conf import settings
|
|
146
|
+
|
|
147
|
+
conf = _settings()
|
|
148
|
+
origin = sock.headers.get("origin")
|
|
149
|
+
if origin is None:
|
|
150
|
+
return not conf.get("REQUIRE_ORIGIN", False)
|
|
151
|
+
|
|
152
|
+
allowed = conf.get("ALLOWED_ORIGINS")
|
|
153
|
+
if allowed is not None:
|
|
154
|
+
if "*" in allowed:
|
|
155
|
+
return True
|
|
156
|
+
return origin in allowed or _host_of(origin) in allowed
|
|
157
|
+
|
|
158
|
+
# Por defecto: ALLOWED_HOSTS + CSRF_TRUSTED_ORIGINS, como hace Django.
|
|
159
|
+
host = _host_of(origin)
|
|
160
|
+
if not host:
|
|
161
|
+
return False
|
|
162
|
+
|
|
163
|
+
trusted = {
|
|
164
|
+
_host_of(o) for o in getattr(settings, "CSRF_TRUSTED_ORIGINS", []) or []
|
|
165
|
+
}
|
|
166
|
+
if host in trusted:
|
|
167
|
+
return True
|
|
168
|
+
|
|
169
|
+
hosts = list(getattr(settings, "ALLOWED_HOSTS", []) or [])
|
|
170
|
+
if settings.DEBUG and not hosts:
|
|
171
|
+
hosts = ["localhost", "127.0.0.1", "[::1]"]
|
|
172
|
+
return any(_host_matches(host, pattern) for pattern in hosts)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _host_of(origin: str) -> str:
|
|
176
|
+
"""'https://ejemplo.com:8000' -> 'ejemplo.com'."""
|
|
177
|
+
try:
|
|
178
|
+
return (urlparse(origin).hostname or "").lower()
|
|
179
|
+
except ValueError:
|
|
180
|
+
return ""
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _host_matches(host: str, pattern: str) -> bool:
|
|
184
|
+
pattern = pattern.lower()
|
|
185
|
+
if pattern == "*":
|
|
186
|
+
return True
|
|
187
|
+
if pattern.startswith("."): # ".ejemplo.com" cubre subdominios y el apex
|
|
188
|
+
return host == pattern[1:] or host.endswith(pattern)
|
|
189
|
+
if pattern.startswith("*."):
|
|
190
|
+
return host == pattern[2:] or host.endswith(pattern[1:])
|
|
191
|
+
return host == pattern
|
django_socket/events.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Enrutado de mensajes JSON por tipo.
|
|
2
|
+
|
|
3
|
+
Casi toda app que habla JSON por el socket manda `{"type": "algo", ...}` y
|
|
4
|
+
acaba con un if/elif largo dentro del bucle. Esto lo convierte en funciones con
|
|
5
|
+
nombre, sin que dejes de ver el flujo:
|
|
6
|
+
|
|
7
|
+
from django_socket import Events, ws
|
|
8
|
+
|
|
9
|
+
chat = Events()
|
|
10
|
+
|
|
11
|
+
@chat.on("mensaje")
|
|
12
|
+
async def mensaje(sock, datos):
|
|
13
|
+
await sock.broadcast({"type": "mensaje", "texto": datos["texto"]})
|
|
14
|
+
|
|
15
|
+
@chat.on("escribiendo")
|
|
16
|
+
async def escribiendo(sock): # si no usas los datos, no los pidas
|
|
17
|
+
await sock.broadcast({"type": "escribiendo"}, exclude_self=True)
|
|
18
|
+
|
|
19
|
+
@ws("chat/<str:room>/", group="room:{room}")
|
|
20
|
+
async def handler(sock, room):
|
|
21
|
+
await chat.run(sock)
|
|
22
|
+
|
|
23
|
+
Es opcional del todo: `async for msg in sock` sigue estando ahi y no necesitas
|
|
24
|
+
saber que esto existe.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import inspect
|
|
30
|
+
import logging
|
|
31
|
+
|
|
32
|
+
from .websocket import InvalidJSON
|
|
33
|
+
|
|
34
|
+
logger = logging.getLogger("django_socket")
|
|
35
|
+
|
|
36
|
+
CUALQUIERA = "*"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class Events:
|
|
40
|
+
"""
|
|
41
|
+
Despacha mensajes JSON segun un campo (por defecto `"type"`).
|
|
42
|
+
|
|
43
|
+
`key` -- el campo que decide el tipo. Usa `Events(key="action")` si tu
|
|
44
|
+
protocolo se llama de otra forma.
|
|
45
|
+
`strict` -- que hacer con un tipo que nadie maneja. Por defecto se ignora
|
|
46
|
+
(y se anota en el log); con `strict=True` se cierra con 4400.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def __init__(self, key: str = "type", strict: bool = False):
|
|
50
|
+
self.key = key
|
|
51
|
+
self.strict = strict
|
|
52
|
+
self._handlers: dict[str, tuple] = {}
|
|
53
|
+
|
|
54
|
+
def on(self, *tipos: str):
|
|
55
|
+
"""
|
|
56
|
+
Registra un handler para uno o varios tipos.
|
|
57
|
+
|
|
58
|
+
@chat.on("mensaje")
|
|
59
|
+
@chat.on("entrar", "salir") # varios de golpe
|
|
60
|
+
@chat.on("*") # lo que no case con nada mas
|
|
61
|
+
|
|
62
|
+
El handler puede pedir los datos o no:
|
|
63
|
+
|
|
64
|
+
async def handler(sock, datos) -> datos = el mensaje sin el campo key
|
|
65
|
+
async def handler(sock) -> te basta con saber que llego
|
|
66
|
+
"""
|
|
67
|
+
if not tipos:
|
|
68
|
+
raise TypeError("@on() necesita al menos un tipo: @on('mensaje')")
|
|
69
|
+
|
|
70
|
+
def decorator(fn):
|
|
71
|
+
if not inspect.iscoroutinefunction(fn):
|
|
72
|
+
raise TypeError(
|
|
73
|
+
f"@on espera 'async def', y {fn.__name__} es una funcion "
|
|
74
|
+
f"normal."
|
|
75
|
+
)
|
|
76
|
+
quiere_datos = _quiere_datos(fn)
|
|
77
|
+
for tipo in tipos:
|
|
78
|
+
if tipo in self._handlers:
|
|
79
|
+
anterior = self._handlers[tipo][0].__name__
|
|
80
|
+
raise ValueError(
|
|
81
|
+
f"El tipo {tipo!r} ya lo maneja {anterior}."
|
|
82
|
+
)
|
|
83
|
+
self._handlers[tipo] = (fn, quiere_datos)
|
|
84
|
+
return fn
|
|
85
|
+
|
|
86
|
+
return decorator
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def tipos(self) -> list[str]:
|
|
90
|
+
return sorted(self._handlers)
|
|
91
|
+
|
|
92
|
+
async def run(self, sock) -> None:
|
|
93
|
+
"""Consume mensajes hasta que el cliente cierra."""
|
|
94
|
+
async for msg in sock:
|
|
95
|
+
await self.handle(sock, msg.json())
|
|
96
|
+
|
|
97
|
+
async def handle(self, sock, datos) -> None:
|
|
98
|
+
"""Despacha un mensaje ya parseado. Util para testear un handler suelto."""
|
|
99
|
+
if not isinstance(datos, dict):
|
|
100
|
+
raise InvalidJSON(datos, f"se esperaba un objeto con {self.key!r}")
|
|
101
|
+
|
|
102
|
+
tipo = datos.get(self.key)
|
|
103
|
+
entrada = self._handlers.get(tipo) or self._handlers.get(CUALQUIERA)
|
|
104
|
+
|
|
105
|
+
if entrada is None:
|
|
106
|
+
if self.strict:
|
|
107
|
+
raise InvalidJSON(
|
|
108
|
+
datos, f"tipo {tipo!r} desconocido; hay: {self.tipos}"
|
|
109
|
+
)
|
|
110
|
+
# A nivel WARNING a proposito: casi siempre es una errata en el
|
|
111
|
+
# nombre del tipo, y en DEBUG no la ve nadie. Si tu protocolo manda
|
|
112
|
+
# tipos que de verdad quieres ignorar, registra un @on("*") vacio.
|
|
113
|
+
logger.warning(
|
|
114
|
+
"django_socket: nadie maneja %s=%r en %s (registrados: %s)",
|
|
115
|
+
self.key, tipo, sock.path, self.tipos or "ninguno",
|
|
116
|
+
)
|
|
117
|
+
return
|
|
118
|
+
|
|
119
|
+
handler, quiere_datos = entrada
|
|
120
|
+
if quiere_datos:
|
|
121
|
+
await handler(sock, {k: v for k, v in datos.items() if k != self.key})
|
|
122
|
+
else:
|
|
123
|
+
await handler(sock)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _quiere_datos(fn) -> bool:
|
|
127
|
+
"""
|
|
128
|
+
Mira si el handler pide el payload ademas del socket.
|
|
129
|
+
|
|
130
|
+
Se resuelve una vez al registrar, no en cada mensaje.
|
|
131
|
+
"""
|
|
132
|
+
params = [
|
|
133
|
+
p for p in inspect.signature(fn).parameters.values()
|
|
134
|
+
if p.kind in (p.POSITIONAL_ONLY, p.POSITIONAL_OR_KEYWORD)
|
|
135
|
+
]
|
|
136
|
+
if len(params) == 1:
|
|
137
|
+
return False
|
|
138
|
+
if len(params) == 2:
|
|
139
|
+
return True
|
|
140
|
+
raise TypeError(
|
|
141
|
+
f"{fn.__name__} debe aceptar (sock) o (sock, datos), no "
|
|
142
|
+
f"{len(params)} parametros posicionales."
|
|
143
|
+
)
|