django-socket 0.2.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 +61 -0
- django_socket/apps.py +32 -0
- django_socket/asgi.py +62 -0
- django_socket/auth.py +10 -0
- django_socket/authentication.py +278 -0
- django_socket/checks.py +114 -0
- django_socket/dispatch.py +208 -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/middleware.py +166 -0
- django_socket/patch.py +54 -0
- django_socket/py.typed +0 -0
- django_socket/ratelimit.py +93 -0
- django_socket/routing.py +148 -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 +602 -0
- django_socket-0.2.0.dist-info/METADATA +1208 -0
- django_socket-0.2.0.dist-info/RECORD +28 -0
- django_socket-0.2.0.dist-info/WHEEL +5 -0
- django_socket-0.2.0.dist-info/licenses/LICENSE +21 -0
- django_socket-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,61 @@
|
|
|
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 .authentication import extraer_token, 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
|
+
"extraer_token",
|
|
46
|
+
# Tipos, para anotar
|
|
47
|
+
"WebSocket",
|
|
48
|
+
"Message",
|
|
49
|
+
"WebSocketDisconnect",
|
|
50
|
+
"WebSocketClosed",
|
|
51
|
+
"InvalidJSON",
|
|
52
|
+
# Puntos de extension
|
|
53
|
+
"ASGIApplication",
|
|
54
|
+
"BaseLayer",
|
|
55
|
+
"MemoryLayer",
|
|
56
|
+
"RedisLayer",
|
|
57
|
+
"set_layer",
|
|
58
|
+
"group_size",
|
|
59
|
+
"get_routes",
|
|
60
|
+
"__version__",
|
|
61
|
+
]
|
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,10 @@
|
|
|
1
|
+
"""Compatibilidad. La implementacion vive ahora en `authentication.py`.
|
|
2
|
+
|
|
3
|
+
Se mantiene porque `django_socket.auth` existia en 0.1.0 y alguien pudo
|
|
4
|
+
importarlo. Lo nuevo (autenticadores conectables, token) esta en
|
|
5
|
+
`django_socket.authentication`.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from .authentication import login_required, resolve # noqa: F401
|
|
9
|
+
|
|
10
|
+
__all__ = ["resolve", "login_required"]
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
"""Autenticacion conectable.
|
|
2
|
+
|
|
3
|
+
Un autenticador es `async def (sock) -> user | None`. Se prueban en orden y
|
|
4
|
+
gana el primero que devuelva algo:
|
|
5
|
+
|
|
6
|
+
DJANGO_SOCKET = {
|
|
7
|
+
"AUTH": ["session", "token"],
|
|
8
|
+
"TOKEN_RESOLVER": "miapp.auth.desde_jwt",
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
o por ruta, cuando solo un endpoint lo necesita:
|
|
12
|
+
|
|
13
|
+
@ws("feed/", auth="token")
|
|
14
|
+
@ws("panel/", auth=["session", "token"])
|
|
15
|
+
@ws("publico/", auth=False) # ni lo intentes
|
|
16
|
+
|
|
17
|
+
Escribir el tuyo es una funcion:
|
|
18
|
+
|
|
19
|
+
async def por_api_key(sock):
|
|
20
|
+
clave = sock.query_params.get("k")
|
|
21
|
+
return await Cliente.objects.filter(api_key=clave).afirst()
|
|
22
|
+
|
|
23
|
+
DJANGO_SOCKET = {"AUTH": ["miapp.auth.por_api_key"]}
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import logging
|
|
29
|
+
from typing import Any, Callable
|
|
30
|
+
|
|
31
|
+
logger = logging.getLogger("django_socket")
|
|
32
|
+
|
|
33
|
+
# Como llega el token, en orden de preferencia.
|
|
34
|
+
ESQUEMA = "bearer"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# --------------------------------------------------------------------- sesion
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class _SessionCarrier:
|
|
41
|
+
"""Lo minimo que `django.contrib.auth.aget_user` espera de un request."""
|
|
42
|
+
|
|
43
|
+
__slots__ = ("session",)
|
|
44
|
+
|
|
45
|
+
def __init__(self, session):
|
|
46
|
+
self.session = session
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
async def session(sock) -> Any | None:
|
|
50
|
+
"""
|
|
51
|
+
La cookie de sesion de Django. Es el modo por defecto.
|
|
52
|
+
|
|
53
|
+
Solo funciona si el navegador manda la cookie, o sea con el frontend
|
|
54
|
+
servido desde el mismo sitio. Para un SPA en otro dominio o una app movil
|
|
55
|
+
no hay cookie: usa `token`.
|
|
56
|
+
"""
|
|
57
|
+
from importlib import import_module
|
|
58
|
+
|
|
59
|
+
from django.apps import apps
|
|
60
|
+
from django.conf import settings
|
|
61
|
+
|
|
62
|
+
if not (
|
|
63
|
+
apps.is_installed("django.contrib.auth")
|
|
64
|
+
and apps.is_installed("django.contrib.sessions")
|
|
65
|
+
):
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
engine = import_module(settings.SESSION_ENGINE)
|
|
69
|
+
clave = sock.cookies.get(settings.SESSION_COOKIE_NAME)
|
|
70
|
+
sock.session = engine.SessionStore(clave)
|
|
71
|
+
|
|
72
|
+
try:
|
|
73
|
+
from django.contrib.auth import aget_user
|
|
74
|
+
except ImportError: # Django < 5.0
|
|
75
|
+
from asgiref.sync import sync_to_async
|
|
76
|
+
from django.contrib.auth import get_user
|
|
77
|
+
|
|
78
|
+
user = await sync_to_async(get_user)(_SessionCarrier(sock.session))
|
|
79
|
+
else:
|
|
80
|
+
user = await aget_user(_SessionCarrier(sock.session))
|
|
81
|
+
|
|
82
|
+
return user if getattr(user, "is_authenticated", False) else None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# ---------------------------------------------------------------------- token
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def extraer_token(sock) -> str | None:
|
|
89
|
+
"""
|
|
90
|
+
Saca el token de donde el cliente haya podido ponerlo.
|
|
91
|
+
|
|
92
|
+
Hay tres sitios porque **el navegador no puede fijar cabeceras** en un
|
|
93
|
+
WebSocket: la API `new WebSocket(url, protocols)` solo deja tocar la URL y
|
|
94
|
+
`Sec-WebSocket-Protocol`. Asi que:
|
|
95
|
+
|
|
96
|
+
1. `Sec-WebSocket-Protocol: bearer, <token>` -- la via recomendada para
|
|
97
|
+
navegadores. No se ve en la URL, luego no acaba en los logs.
|
|
98
|
+
2. `Authorization: Bearer <token>` -- para clientes nativos, que si pueden
|
|
99
|
+
poner cabeceras.
|
|
100
|
+
3. `?token=<token>` -- funciona en todas partes, pero **queda escrito en
|
|
101
|
+
los logs de acceso del servidor y de cualquier proxy por el que pase**.
|
|
102
|
+
Usalo solo con tokens de un solo uso y vida corta.
|
|
103
|
+
"""
|
|
104
|
+
protocolos = [p.strip() for p in sock.subprotocols]
|
|
105
|
+
if len(protocolos) >= 2 and protocolos[0].lower() == ESQUEMA:
|
|
106
|
+
return protocolos[1]
|
|
107
|
+
|
|
108
|
+
cabecera = sock.headers.get("authorization", "")
|
|
109
|
+
if cabecera.lower().startswith(ESQUEMA + " "):
|
|
110
|
+
return cabecera[len(ESQUEMA) + 1:].strip()
|
|
111
|
+
|
|
112
|
+
return sock.query_params.get("token")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
async def token(sock) -> Any | None:
|
|
116
|
+
"""
|
|
117
|
+
Un token, resuelto por la funcion que tu indiques.
|
|
118
|
+
|
|
119
|
+
La libreria no sabe validar tu token -- puede ser un JWT, el de DRF, o algo
|
|
120
|
+
tuyo -- asi que solo hace el transporte y te delega la parte que importa:
|
|
121
|
+
|
|
122
|
+
DJANGO_SOCKET = {"TOKEN_RESOLVER": "miapp.auth.desde_jwt"}
|
|
123
|
+
|
|
124
|
+
async def desde_jwt(token):
|
|
125
|
+
datos = jwt.decode(token, KEY, algorithms=["HS256"])
|
|
126
|
+
return await User.objects.filter(pk=datos["sub"]).afirst()
|
|
127
|
+
|
|
128
|
+
Si no configuras `TOKEN_RESOLVER` y tienes `rest_framework.authtoken`
|
|
129
|
+
instalado, se usa ese como atajo razonable.
|
|
130
|
+
"""
|
|
131
|
+
crudo = extraer_token(sock)
|
|
132
|
+
if not crudo:
|
|
133
|
+
return None
|
|
134
|
+
|
|
135
|
+
resolver = _get_token_resolver()
|
|
136
|
+
if resolver is None:
|
|
137
|
+
logger.warning(
|
|
138
|
+
"django_socket: llego un token pero no hay quien lo valide. "
|
|
139
|
+
"Define DJANGO_SOCKET['TOKEN_RESOLVER'] con una funcion "
|
|
140
|
+
"async(token) -> user | None."
|
|
141
|
+
)
|
|
142
|
+
return None
|
|
143
|
+
|
|
144
|
+
try:
|
|
145
|
+
return await resolver(crudo)
|
|
146
|
+
except Exception:
|
|
147
|
+
# Un token invalido es lo normal, no un incidente: no ensucies el log
|
|
148
|
+
# con una traza por cada intento.
|
|
149
|
+
logger.debug("django_socket: el resolver rechazo el token", exc_info=True)
|
|
150
|
+
return None
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
_resolver_cache: Callable | None = None
|
|
154
|
+
_resolver_resuelto = False
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _get_token_resolver() -> Callable | None:
|
|
158
|
+
global _resolver_cache, _resolver_resuelto
|
|
159
|
+
if _resolver_resuelto:
|
|
160
|
+
return _resolver_cache
|
|
161
|
+
|
|
162
|
+
from django.conf import settings
|
|
163
|
+
from django.utils.module_loading import import_string
|
|
164
|
+
|
|
165
|
+
conf = getattr(settings, "DJANGO_SOCKET", {}) or {}
|
|
166
|
+
ruta = conf.get("TOKEN_RESOLVER")
|
|
167
|
+
|
|
168
|
+
if ruta:
|
|
169
|
+
_resolver_cache = import_string(ruta) if isinstance(ruta, str) else ruta
|
|
170
|
+
else:
|
|
171
|
+
_resolver_cache = _resolver_drf()
|
|
172
|
+
|
|
173
|
+
_resolver_resuelto = True
|
|
174
|
+
return _resolver_cache
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _resolver_drf() -> Callable | None:
|
|
178
|
+
"""Atajo para quien ya use `rest_framework.authtoken`."""
|
|
179
|
+
from django.apps import apps
|
|
180
|
+
|
|
181
|
+
if not apps.is_installed("rest_framework.authtoken"):
|
|
182
|
+
return None
|
|
183
|
+
|
|
184
|
+
async def desde_drf(crudo):
|
|
185
|
+
from rest_framework.authtoken.models import Token as DRFToken
|
|
186
|
+
|
|
187
|
+
fila = await DRFToken.objects.select_related("user").filter(
|
|
188
|
+
key=crudo
|
|
189
|
+
).afirst()
|
|
190
|
+
return fila.user if fila else None
|
|
191
|
+
|
|
192
|
+
return desde_drf
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
# ------------------------------------------------------------------ registro
|
|
196
|
+
|
|
197
|
+
INCORPORADOS: dict[str, Callable] = {"session": session, "token": token}
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def resolver_lista(spec) -> list[Callable]:
|
|
201
|
+
"""Normaliza lo que venga en `auth=` o en settings a una lista de funciones."""
|
|
202
|
+
from django.utils.module_loading import import_string
|
|
203
|
+
|
|
204
|
+
if spec is True or spec is None:
|
|
205
|
+
spec = _por_defecto()
|
|
206
|
+
if isinstance(spec, (str, bytes)) or callable(spec):
|
|
207
|
+
spec = [spec]
|
|
208
|
+
|
|
209
|
+
salida = []
|
|
210
|
+
for item in spec:
|
|
211
|
+
if callable(item):
|
|
212
|
+
salida.append(item)
|
|
213
|
+
elif item in INCORPORADOS:
|
|
214
|
+
salida.append(INCORPORADOS[item])
|
|
215
|
+
else:
|
|
216
|
+
try:
|
|
217
|
+
salida.append(import_string(item))
|
|
218
|
+
except ImportError as exc:
|
|
219
|
+
raise ValueError(
|
|
220
|
+
f"Autenticador desconocido: {item!r}. Usa "
|
|
221
|
+
f"{sorted(INCORPORADOS)}, una ruta importable, o una "
|
|
222
|
+
f"funcion async(sock) -> user | None."
|
|
223
|
+
) from exc
|
|
224
|
+
return salida
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _por_defecto():
|
|
228
|
+
from django.conf import settings
|
|
229
|
+
|
|
230
|
+
conf = getattr(settings, "DJANGO_SOCKET", {}) or {}
|
|
231
|
+
return conf.get("AUTH", ["session"])
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
async def resolve(sock, spec=True) -> None:
|
|
235
|
+
"""Rellena `sock.user` con el primer autenticador que reconozca a alguien."""
|
|
236
|
+
from django.contrib.auth.models import AnonymousUser
|
|
237
|
+
|
|
238
|
+
for autenticador in resolver_lista(spec):
|
|
239
|
+
try:
|
|
240
|
+
user = await autenticador(sock)
|
|
241
|
+
except Exception:
|
|
242
|
+
logger.exception(
|
|
243
|
+
"django_socket: el autenticador %s fallo",
|
|
244
|
+
getattr(autenticador, "__name__", autenticador),
|
|
245
|
+
)
|
|
246
|
+
continue
|
|
247
|
+
if user is not None:
|
|
248
|
+
sock.user = user
|
|
249
|
+
return
|
|
250
|
+
|
|
251
|
+
sock.user = AnonymousUser()
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _limpiar_cache_resolver() -> None:
|
|
255
|
+
"""Solo para tests: obliga a releer TOKEN_RESOLVER de settings."""
|
|
256
|
+
global _resolver_cache, _resolver_resuelto
|
|
257
|
+
_resolver_cache, _resolver_resuelto = None, False
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def login_required(handler):
|
|
261
|
+
"""
|
|
262
|
+
Cierra la conexion con 4401 si el usuario no esta autenticado.
|
|
263
|
+
|
|
264
|
+
@ws("panel/")
|
|
265
|
+
@login_required
|
|
266
|
+
async def panel(sock): ...
|
|
267
|
+
"""
|
|
268
|
+
import functools
|
|
269
|
+
|
|
270
|
+
@functools.wraps(handler)
|
|
271
|
+
async def wrapper(sock, *args, **kwargs):
|
|
272
|
+
user = getattr(sock, "user", None)
|
|
273
|
+
if user is None or not user.is_authenticated:
|
|
274
|
+
await sock.close(4401, "Authentication required")
|
|
275
|
+
return
|
|
276
|
+
return await handler(sock, *args, **kwargs)
|
|
277
|
+
|
|
278
|
+
return wrapper
|
django_socket/checks.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
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
|
+
"AUTH",
|
|
24
|
+
"TOKEN_RESOLVER",
|
|
25
|
+
"MIDDLEWARE",
|
|
26
|
+
"RATE_LIMIT",
|
|
27
|
+
"RATE_LIMIT_BURST",
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@register()
|
|
32
|
+
def check_settings(app_configs, **kwargs):
|
|
33
|
+
from django.conf import settings
|
|
34
|
+
|
|
35
|
+
problems = []
|
|
36
|
+
conf = getattr(settings, "DJANGO_SOCKET", {}) or {}
|
|
37
|
+
|
|
38
|
+
unknown = set(conf) - KNOWN_KEYS
|
|
39
|
+
if unknown:
|
|
40
|
+
problems.append(
|
|
41
|
+
Error(
|
|
42
|
+
f"Opcion(es) desconocida(s) en DJANGO_SOCKET: {sorted(unknown)}.",
|
|
43
|
+
hint=f"Las validas son: {sorted(KNOWN_KEYS)}.",
|
|
44
|
+
id=E001,
|
|
45
|
+
)
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
allowed = conf.get("ALLOWED_ORIGINS")
|
|
49
|
+
if allowed and "*" in allowed and not settings.DEBUG:
|
|
50
|
+
problems.append(
|
|
51
|
+
Warning(
|
|
52
|
+
"DJANGO_SOCKET['ALLOWED_ORIGINS'] contiene '*' con DEBUG=False.",
|
|
53
|
+
hint=(
|
|
54
|
+
"Cualquier web podra abrir un socket contra la tuya con las "
|
|
55
|
+
"cookies de sesion de tus usuarios (cross-site WebSocket "
|
|
56
|
+
"hijacking). Enumera los origenes que confias."
|
|
57
|
+
),
|
|
58
|
+
id=W003,
|
|
59
|
+
)
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
return problems
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@register()
|
|
66
|
+
def check_routes(app_configs, **kwargs):
|
|
67
|
+
from . import routing
|
|
68
|
+
|
|
69
|
+
if routing.get_routes():
|
|
70
|
+
return []
|
|
71
|
+
return [
|
|
72
|
+
Warning(
|
|
73
|
+
"django_socket esta instalado pero no hay ninguna ruta websocket.",
|
|
74
|
+
hint=(
|
|
75
|
+
"Crea <tu_app>/sockets.py y decora un 'async def' con @ws('...'). "
|
|
76
|
+
"Se autodescubre igual que admin.py."
|
|
77
|
+
),
|
|
78
|
+
id=W002,
|
|
79
|
+
)
|
|
80
|
+
]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
W004 = "django_socket.W004" # token por query sin resolver configurado
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@register()
|
|
87
|
+
def check_auth(app_configs, **kwargs):
|
|
88
|
+
"""Avisa de la combinacion que deja a todo el mundo anonimo en silencio."""
|
|
89
|
+
from django.conf import settings
|
|
90
|
+
|
|
91
|
+
conf = getattr(settings, "DJANGO_SOCKET", {}) or {}
|
|
92
|
+
autenticadores = conf.get("AUTH", ["session"])
|
|
93
|
+
usa_token = any(
|
|
94
|
+
a == "token" or getattr(a, "__name__", "") == "token" for a in autenticadores
|
|
95
|
+
)
|
|
96
|
+
if usa_token and not conf.get("TOKEN_RESOLVER"):
|
|
97
|
+
from django.apps import apps
|
|
98
|
+
|
|
99
|
+
if not apps.is_installed("rest_framework.authtoken"):
|
|
100
|
+
return [
|
|
101
|
+
Warning(
|
|
102
|
+
"DJANGO_SOCKET['AUTH'] incluye 'token' pero no hay "
|
|
103
|
+
"TOKEN_RESOLVER.",
|
|
104
|
+
hint=(
|
|
105
|
+
"La libreria transporta el token pero no sabe validarlo. "
|
|
106
|
+
"Define TOKEN_RESOLVER con una funcion "
|
|
107
|
+
"async(token) -> user | None, o instala "
|
|
108
|
+
"rest_framework.authtoken. Sin eso, todo el mundo "
|
|
109
|
+
"entra como anonimo y no es evidente por que."
|
|
110
|
+
),
|
|
111
|
+
id=W004,
|
|
112
|
+
)
|
|
113
|
+
]
|
|
114
|
+
return []
|