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.
@@ -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
@@ -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 []