wsbuilder 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.
- wsbuilder/__init__.py +27 -0
- wsbuilder/__main__.py +17 -0
- wsbuilder/framework.py +662 -0
- wsbuilder/ws_demo.py +1837 -0
- wsbuilder-0.1.0.dist-info/METADATA +77 -0
- wsbuilder-0.1.0.dist-info/RECORD +10 -0
- wsbuilder-0.1.0.dist-info/WHEEL +5 -0
- wsbuilder-0.1.0.dist-info/entry_points.txt +3 -0
- wsbuilder-0.1.0.dist-info/licenses/LICENSE +21 -0
- wsbuilder-0.1.0.dist-info/top_level.txt +1 -0
wsbuilder/ws_demo.py
ADDED
|
@@ -0,0 +1,1837 @@
|
|
|
1
|
+
# enhanced_server.py (versión basada en clases Threading + ORM SQLite)
|
|
2
|
+
# Servidor HTTP + WebSocket + REST con funcionalidades WebSocket ampliadas:
|
|
3
|
+
# - text / binary
|
|
4
|
+
# - fragmentation (continuation frames)
|
|
5
|
+
# - control frames: ping/pong/close (parseo de close code + reason)
|
|
6
|
+
# - negociación de subprotocolos (Sec-WebSocket-Protocol)
|
|
7
|
+
# - API REST para listar clientes, broadcast, ping, cerrar
|
|
8
|
+
# - ORM SQLite con concurrencia y transacciones anidadas
|
|
9
|
+
# UNICAS librerías externas: socket, threading, uuid, time, json, sqlite3
|
|
10
|
+
|
|
11
|
+
import socket
|
|
12
|
+
import threading
|
|
13
|
+
import uuid
|
|
14
|
+
import time
|
|
15
|
+
import json
|
|
16
|
+
import sqlite3
|
|
17
|
+
|
|
18
|
+
HOST = '0.0.0.0'
|
|
19
|
+
PORT = 8765
|
|
20
|
+
|
|
21
|
+
MAGIC_WS = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
|
|
22
|
+
|
|
23
|
+
# ============================================================
|
|
24
|
+
# SHA1 (implementación pura en Python para no importar hashlib)
|
|
25
|
+
# ============================================================
|
|
26
|
+
|
|
27
|
+
def _left_rotate(n, b):
|
|
28
|
+
return ((n << b) | (n >> (32 - b))) & 0xffffffff
|
|
29
|
+
|
|
30
|
+
def sha1(data_bytes):
|
|
31
|
+
message = bytearray(data_bytes)
|
|
32
|
+
orig_len_bits = (8 * len(message)) & 0xffffffffffffffff
|
|
33
|
+
message.append(0x80)
|
|
34
|
+
while (len(message) * 8) % 512 != 448:
|
|
35
|
+
message.append(0)
|
|
36
|
+
message += orig_len_bits.to_bytes(8, 'big')
|
|
37
|
+
|
|
38
|
+
h0 = 0x67452301
|
|
39
|
+
h1 = 0xEFCDAB89
|
|
40
|
+
h2 = 0x98BADCFE
|
|
41
|
+
h3 = 0x10325476
|
|
42
|
+
h4 = 0xC3D2E1F0
|
|
43
|
+
|
|
44
|
+
for i in range(0, len(message), 64):
|
|
45
|
+
w = [0] * 80
|
|
46
|
+
chunk = message[i:i+64]
|
|
47
|
+
for j in range(16):
|
|
48
|
+
w[j] = int.from_bytes(chunk[j*4:(j+1)*4], 'big')
|
|
49
|
+
for j in range(16, 80):
|
|
50
|
+
w[j] = _left_rotate(w[j-3] ^ w[j-8] ^ w[j-14] ^ w[j-16], 1)
|
|
51
|
+
|
|
52
|
+
a, b, c, d, e = h0, h1, h2, h3, h4
|
|
53
|
+
for t in range(80):
|
|
54
|
+
if 0 <= t <= 19:
|
|
55
|
+
f = (b & c) | ((~b) & d)
|
|
56
|
+
k = 0x5A827999
|
|
57
|
+
elif 20 <= t <= 39:
|
|
58
|
+
f = b ^ c ^ d
|
|
59
|
+
k = 0x6ED9EBA1
|
|
60
|
+
elif 40 <= t <= 59:
|
|
61
|
+
f = (b & c) | (b & d) | (c & d)
|
|
62
|
+
k = 0x8F1BBCDC
|
|
63
|
+
else:
|
|
64
|
+
f = b ^ c ^ d
|
|
65
|
+
k = 0xCA62C1D6
|
|
66
|
+
tmp = (_left_rotate(a, 5) + f + e + k + w[t]) & 0xffffffff
|
|
67
|
+
e = d
|
|
68
|
+
d = c
|
|
69
|
+
c = _left_rotate(b, 30)
|
|
70
|
+
b = a
|
|
71
|
+
a = tmp
|
|
72
|
+
|
|
73
|
+
h0 = (h0 + a) & 0xffffffff
|
|
74
|
+
h1 = (h1 + b) & 0xffffffff
|
|
75
|
+
h2 = (h2 + c) & 0xffffffff
|
|
76
|
+
h3 = (h3 + d) & 0xffffffff
|
|
77
|
+
h4 = (h4 + e) & 0xffffffff
|
|
78
|
+
|
|
79
|
+
digest = b''.join(x.to_bytes(4, 'big') for x in [h0, h1, h2, h3, h4])
|
|
80
|
+
return digest
|
|
81
|
+
|
|
82
|
+
# ============================================================
|
|
83
|
+
# Base64 (implementación mínima para bytes -> base64 str)
|
|
84
|
+
# ============================================================
|
|
85
|
+
|
|
86
|
+
B64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
|
|
87
|
+
|
|
88
|
+
def base64_encode(data_bytes):
|
|
89
|
+
res = []
|
|
90
|
+
i = 0
|
|
91
|
+
n = len(data_bytes)
|
|
92
|
+
while i < n:
|
|
93
|
+
b = data_bytes[i:i+3]
|
|
94
|
+
i += 3
|
|
95
|
+
pad = 3 - len(b)
|
|
96
|
+
val = 0
|
|
97
|
+
for x in b:
|
|
98
|
+
val = (val << 8) + x
|
|
99
|
+
val <<= (pad * 8)
|
|
100
|
+
for j in range(18, -1, -6):
|
|
101
|
+
idx = (val >> j) & 0x3F
|
|
102
|
+
res.append(B64_ALPHABET[idx])
|
|
103
|
+
if pad:
|
|
104
|
+
res[-pad:] = '=' * pad
|
|
105
|
+
return ''.join(res)
|
|
106
|
+
|
|
107
|
+
# ============================================================
|
|
108
|
+
# Utilidades WebSocket frames
|
|
109
|
+
# ============================================================
|
|
110
|
+
|
|
111
|
+
def recv_exact(conn, n):
|
|
112
|
+
data = b''
|
|
113
|
+
while len(data) < n:
|
|
114
|
+
chunk = conn.recv(n - len(data))
|
|
115
|
+
if not chunk:
|
|
116
|
+
raise ConnectionError("connection closed")
|
|
117
|
+
data += chunk
|
|
118
|
+
return data
|
|
119
|
+
|
|
120
|
+
def read_ws_frame_raw(conn):
|
|
121
|
+
"""
|
|
122
|
+
Lee un único frame y devuelve (fin, opcode, payload_bytes, masked_flag, mask_bytes).
|
|
123
|
+
No realiza ensamblado de fragmentation (eso lo hace el loop).
|
|
124
|
+
"""
|
|
125
|
+
hdr = recv_exact(conn, 2)
|
|
126
|
+
b1, b2 = hdr[0], hdr[1]
|
|
127
|
+
fin = (b1 >> 7) & 1
|
|
128
|
+
opcode = b1 & 0x0f
|
|
129
|
+
masked = (b2 >> 7) & 1
|
|
130
|
+
payload_len = b2 & 0x7f
|
|
131
|
+
|
|
132
|
+
if payload_len == 126:
|
|
133
|
+
ext = recv_exact(conn, 2)
|
|
134
|
+
payload_len = int.from_bytes(ext, 'big')
|
|
135
|
+
elif payload_len == 127:
|
|
136
|
+
ext = recv_exact(conn, 8)
|
|
137
|
+
payload_len = int.from_bytes(ext, 'big')
|
|
138
|
+
|
|
139
|
+
mask = None
|
|
140
|
+
if masked:
|
|
141
|
+
mask = recv_exact(conn, 4)
|
|
142
|
+
|
|
143
|
+
payload = b''
|
|
144
|
+
if payload_len:
|
|
145
|
+
payload = recv_exact(conn, payload_len)
|
|
146
|
+
if masked and mask:
|
|
147
|
+
payload = bytes(b ^ mask[i % 4] for i, b in enumerate(payload))
|
|
148
|
+
return fin, opcode, payload, bool(masked), mask
|
|
149
|
+
|
|
150
|
+
def make_ws_frame_bytes(opcode, payload=b''):
|
|
151
|
+
"""
|
|
152
|
+
Construye un frame desde el servidor hacia el cliente (no enmascarado).
|
|
153
|
+
opcode: int (1=text,2=binary,8=close,9=ping,10=pong,0=continuation)
|
|
154
|
+
payload: bytes
|
|
155
|
+
"""
|
|
156
|
+
fin = 0x80 # siempre FIN=1 salvo que se quiera fragmentar por el servidor
|
|
157
|
+
b1 = fin | (opcode & 0x0f)
|
|
158
|
+
payload_len = len(payload)
|
|
159
|
+
header = bytes([b1])
|
|
160
|
+
|
|
161
|
+
if payload_len <= 125:
|
|
162
|
+
header += bytes([payload_len])
|
|
163
|
+
elif payload_len <= 65535:
|
|
164
|
+
header += bytes([126]) + payload_len.to_bytes(2, 'big')
|
|
165
|
+
else:
|
|
166
|
+
header += bytes([127]) + payload_len.to_bytes(8, 'big')
|
|
167
|
+
|
|
168
|
+
return header + payload
|
|
169
|
+
|
|
170
|
+
def parse_close_payload(payload):
|
|
171
|
+
"""
|
|
172
|
+
Si payload >=2, los primeros 2 bytes son close-code, resto utf-8 reason.
|
|
173
|
+
Devuelve (code:int or None, reason:str or None)
|
|
174
|
+
"""
|
|
175
|
+
if not payload:
|
|
176
|
+
return None, None
|
|
177
|
+
if len(payload) >= 2:
|
|
178
|
+
code = int.from_bytes(payload[:2], 'big')
|
|
179
|
+
reason = ''
|
|
180
|
+
if len(payload) > 2:
|
|
181
|
+
try:
|
|
182
|
+
reason = payload[2:].decode('utf-8', errors='ignore')
|
|
183
|
+
except Exception:
|
|
184
|
+
reason = ''
|
|
185
|
+
return code, reason
|
|
186
|
+
return None, None
|
|
187
|
+
|
|
188
|
+
# ============================================================
|
|
189
|
+
# HTTP helpers
|
|
190
|
+
# ============================================================
|
|
191
|
+
|
|
192
|
+
def parse_http_request(conn):
|
|
193
|
+
data = b''
|
|
194
|
+
while b'\r\n\r\n' not in data:
|
|
195
|
+
chunk = conn.recv(1024)
|
|
196
|
+
if not chunk:
|
|
197
|
+
break
|
|
198
|
+
data += chunk
|
|
199
|
+
if len(data) > 65536:
|
|
200
|
+
break
|
|
201
|
+
header_text, _, remainder = data.partition(b'\r\n\r\n')
|
|
202
|
+
lines = header_text.decode('utf-8', errors='ignore').split('\r\n')
|
|
203
|
+
if not lines:
|
|
204
|
+
return None
|
|
205
|
+
request_line = lines[0]
|
|
206
|
+
parts = request_line.split()
|
|
207
|
+
if len(parts) != 3:
|
|
208
|
+
return None
|
|
209
|
+
method, path, version = parts
|
|
210
|
+
if not version.startswith('HTTP/'):
|
|
211
|
+
return None
|
|
212
|
+
headers = {}
|
|
213
|
+
for line in lines[1:]:
|
|
214
|
+
if ':' in line:
|
|
215
|
+
k, v = line.split(':', 1)
|
|
216
|
+
headers[k.strip().lower()] = v.strip()
|
|
217
|
+
return {
|
|
218
|
+
'method': method,
|
|
219
|
+
'path': path,
|
|
220
|
+
'version': version,
|
|
221
|
+
'headers': headers,
|
|
222
|
+
'remainder': remainder
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
def send_http_response(conn, status_code=200, reason='OK', headers=None, body=b''):
|
|
226
|
+
if headers is None:
|
|
227
|
+
headers = {}
|
|
228
|
+
status_line = f"HTTP/1.1 {status_code} {reason}\r\n"
|
|
229
|
+
hdrs = ''
|
|
230
|
+
lowermap = {k.lower(): v for k, v in headers.items()}
|
|
231
|
+
if 'content-length' not in lowermap:
|
|
232
|
+
headers['Content-Length'] = str(len(body))
|
|
233
|
+
if 'connection' not in lowermap:
|
|
234
|
+
headers['Connection'] = 'close'
|
|
235
|
+
if 'server' not in lowermap:
|
|
236
|
+
headers['Server'] = 'MinimalWS/1.0'
|
|
237
|
+
for k, v in headers.items():
|
|
238
|
+
hdrs += f"{k}: {v}\r\n"
|
|
239
|
+
resp = status_line + hdrs + "\r\n"
|
|
240
|
+
try:
|
|
241
|
+
conn.sendall(resp.encode('utf-8') + body)
|
|
242
|
+
except Exception as e:
|
|
243
|
+
print(f"[HTTP] Error enviando respuesta {status_code}: {e}")
|
|
244
|
+
|
|
245
|
+
def parse_query_string(qs):
|
|
246
|
+
"""
|
|
247
|
+
Parser sencillo de query-string (sin decode de %).
|
|
248
|
+
Ej: "limit=50&foo=bar" -> {"limit": "50", "foo": "bar"}
|
|
249
|
+
"""
|
|
250
|
+
params = {}
|
|
251
|
+
if not qs:
|
|
252
|
+
return params
|
|
253
|
+
parts = qs.split('&')
|
|
254
|
+
for part in parts:
|
|
255
|
+
if not part:
|
|
256
|
+
continue
|
|
257
|
+
if '=' in part:
|
|
258
|
+
k, v = part.split('=', 1)
|
|
259
|
+
else:
|
|
260
|
+
k, v = part, ''
|
|
261
|
+
params[k] = v
|
|
262
|
+
return params
|
|
263
|
+
|
|
264
|
+
# ============================================================
|
|
265
|
+
# HTML page (servida en /)
|
|
266
|
+
# ============================================================
|
|
267
|
+
|
|
268
|
+
INDEX_HTML = """<!doctype html>
|
|
269
|
+
<html>
|
|
270
|
+
<head>
|
|
271
|
+
<meta charset="utf-8" />
|
|
272
|
+
<title>Servidor WS avanzado</title>
|
|
273
|
+
<style>
|
|
274
|
+
body{font-family: Arial, sans-serif; max-width:900px; margin:20px;}
|
|
275
|
+
button, input, select { margin:4px; }
|
|
276
|
+
pre { background:#f0f0f0; padding:8px; overflow:auto; }
|
|
277
|
+
fieldset{margin-bottom:10px;}
|
|
278
|
+
legend{font-weight:bold;}
|
|
279
|
+
#messages{max-height:250px; overflow-y:auto; border:1px solid #ccc; padding:4px;}
|
|
280
|
+
#chat_text{width:70%;}
|
|
281
|
+
</style>
|
|
282
|
+
</head>
|
|
283
|
+
<body>
|
|
284
|
+
<h1>Servidor HTTP + WebSocket (avanzado)</h1>
|
|
285
|
+
<p>Pruebas WebSocket + ORM SQLite (mensajes de chat persistidos).</p>
|
|
286
|
+
<p>
|
|
287
|
+
Support PortHound (optional): BTC
|
|
288
|
+
<code id="btc_address">bc1q3lhxpr9yantvefmvhpd2h4lu0ykf3t45zvuve2</code>
|
|
289
|
+
<button id="copy_btc" type="button">Copiar BTC</button>
|
|
290
|
+
</p>
|
|
291
|
+
|
|
292
|
+
<fieldset>
|
|
293
|
+
<legend>Conexión WebSocket</legend>
|
|
294
|
+
<div>
|
|
295
|
+
<label>Plantillas de subprotocolos:
|
|
296
|
+
<select id="subprotos_preset">
|
|
297
|
+
<option value="">-- seleccionar plantilla --</option>
|
|
298
|
+
<option value="chat">chat</option>
|
|
299
|
+
<option value="chat,json">chat,json</option>
|
|
300
|
+
<option value="chat,json,superchat">chat,json,superchat</option>
|
|
301
|
+
</select>
|
|
302
|
+
</label>
|
|
303
|
+
</div>
|
|
304
|
+
<div>
|
|
305
|
+
<label>Subprotocolos deseados (comma-separated):
|
|
306
|
+
<input id="subprotos" value="chat"/>
|
|
307
|
+
</label>
|
|
308
|
+
</div>
|
|
309
|
+
|
|
310
|
+
<button id="connect">Conectar WS</button>
|
|
311
|
+
<button id="disconnect">Desconectar</button>
|
|
312
|
+
<div id="wsstatus">Estado: cerrado</div>
|
|
313
|
+
<div>ID de este cliente (asignado por servidor): <code id="this_client_id">desconocido</code></div>
|
|
314
|
+
</fieldset>
|
|
315
|
+
|
|
316
|
+
<fieldset>
|
|
317
|
+
<legend>Enviar (cliente)</legend>
|
|
318
|
+
<input id="textmsg" placeholder="Texto..." size="60"/>
|
|
319
|
+
<button id="sendtext">Enviar texto</button>
|
|
320
|
+
<button id="sendbinary">Enviar binario (ejemplo 0..9)</button>
|
|
321
|
+
<button id="sendfragmented">Enviar fragmentado (texto largo dividido)</button>
|
|
322
|
+
</fieldset>
|
|
323
|
+
|
|
324
|
+
<fieldset>
|
|
325
|
+
<legend>Peticiones REST al servidor (control desde página)</legend>
|
|
326
|
+
<button id="listclients">Listar clientes (API) + rellenar select</button>
|
|
327
|
+
<button id="server_broadcast">Pedir broadcast de texto por servidor</button>
|
|
328
|
+
<button id="server_ping">Pedir ping desde servidor a todos</button>
|
|
329
|
+
<button id="server_chat_list">Listar mensajes de chat (API)</button>
|
|
330
|
+
<button id="server_chat_clear">Borrar mensajes de chat (API)</button>
|
|
331
|
+
</fieldset>
|
|
332
|
+
|
|
333
|
+
<fieldset>
|
|
334
|
+
<legend>Cerrar conexión desde servidor</legend>
|
|
335
|
+
<div>
|
|
336
|
+
<label>Seleccionar cliente:
|
|
337
|
+
<select id="close_client_select">
|
|
338
|
+
<option value="">-- sin selección (usar campo manual) --</option>
|
|
339
|
+
</select>
|
|
340
|
+
</label>
|
|
341
|
+
</div>
|
|
342
|
+
<div>
|
|
343
|
+
<input id="close_client_id" placeholder="client id (manual / opcional)" size="40"/>
|
|
344
|
+
</div>
|
|
345
|
+
<div>
|
|
346
|
+
<input id="close_code" placeholder="Código (1000)" size="8"/>
|
|
347
|
+
<input id="close_reason" placeholder="Razón" size="20"/>
|
|
348
|
+
<button id="server_close">Pedir cerrar</button>
|
|
349
|
+
</div>
|
|
350
|
+
</fieldset>
|
|
351
|
+
|
|
352
|
+
<h3>Mensajes / Log</h3>
|
|
353
|
+
<ul id="messages"></ul>
|
|
354
|
+
|
|
355
|
+
<hr/>
|
|
356
|
+
|
|
357
|
+
<fieldset>
|
|
358
|
+
<legend>Chat sencillo (sobre este servidor, persistido en SQLite)</legend>
|
|
359
|
+
<div>
|
|
360
|
+
<label>Alias:
|
|
361
|
+
<input id="chat_alias" placeholder="tu nombre o nick" size="20"/>
|
|
362
|
+
</label>
|
|
363
|
+
<button id="chat_save_alias">Guardar alias</button>
|
|
364
|
+
</div>
|
|
365
|
+
<div>
|
|
366
|
+
<label>Modo de envío:
|
|
367
|
+
<select id="chat_mode">
|
|
368
|
+
<option value="local">Solo a este WebSocket (echo servidor)</option>
|
|
369
|
+
<option value="broadcast">Broadcast a todos (API /api/ws/broadcast)</option>
|
|
370
|
+
<option value="ping">Ping (API /api/ws/ping)</option>
|
|
371
|
+
</select>
|
|
372
|
+
</label>
|
|
373
|
+
</div>
|
|
374
|
+
<div>
|
|
375
|
+
<input id="chat_text" placeholder="Escribe un mensaje de chat..." />
|
|
376
|
+
<button id="chat_send">Enviar</button>
|
|
377
|
+
<button id="chat_clear">Limpiar log</button>
|
|
378
|
+
</div>
|
|
379
|
+
<p style="font-size:12px;color:#666;">
|
|
380
|
+
Los mensajes que parezcan "[alias] texto" se guardan en SQLite en la tabla "chat_messages".
|
|
381
|
+
Puedes verlos con <code>GET /api/chat/messages?limit=20</code>.
|
|
382
|
+
</p>
|
|
383
|
+
</fieldset>
|
|
384
|
+
|
|
385
|
+
<script>
|
|
386
|
+
let ws = null;
|
|
387
|
+
|
|
388
|
+
function addMsg(s){
|
|
389
|
+
const ul = document.getElementById('messages');
|
|
390
|
+
const li = document.createElement('li');
|
|
391
|
+
const ts = new Date().toISOString();
|
|
392
|
+
li.textContent = '[' + ts + '] ' + s;
|
|
393
|
+
ul.appendChild(li);
|
|
394
|
+
ul.scrollTop = ul.scrollHeight;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
document.getElementById('copy_btc').onclick = function(){
|
|
398
|
+
const el = document.getElementById('btc_address');
|
|
399
|
+
const value = el ? String(el.textContent || '').trim() : '';
|
|
400
|
+
if(!value){
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
if(navigator && navigator.clipboard && navigator.clipboard.writeText){
|
|
404
|
+
navigator.clipboard.writeText(value)
|
|
405
|
+
.then(() => addMsg('[BTC] Dirección copiada al portapapeles.'))
|
|
406
|
+
.catch(() => addMsg('[BTC] No se pudo copiar automáticamente.'));
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
addMsg('[BTC] Copia manual: ' + value);
|
|
410
|
+
};
|
|
411
|
+
|
|
412
|
+
function refreshClientsSelect(list){
|
|
413
|
+
const sel = document.getElementById('close_client_select');
|
|
414
|
+
if(!sel) return;
|
|
415
|
+
sel.innerHTML = '';
|
|
416
|
+
const opt0 = document.createElement('option');
|
|
417
|
+
opt0.value = '';
|
|
418
|
+
opt0.textContent = '-- sin selección (usar campo manual) --';
|
|
419
|
+
sel.appendChild(opt0);
|
|
420
|
+
|
|
421
|
+
if(!Array.isArray(list)) return;
|
|
422
|
+
list.forEach(c => {
|
|
423
|
+
const opt = document.createElement('option');
|
|
424
|
+
opt.value = c.id;
|
|
425
|
+
const proto = c.subprotocol ? (', proto=' + c.subprotocol) : '';
|
|
426
|
+
opt.textContent = c.id + ' [' + c.addr + proto + ']';
|
|
427
|
+
sel.appendChild(opt);
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function getChatAlias(){
|
|
432
|
+
const el = document.getElementById('chat_alias');
|
|
433
|
+
let alias = el ? el.value.trim() : '';
|
|
434
|
+
if(!alias) alias = 'anon';
|
|
435
|
+
return alias;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
(function initAlias(){
|
|
439
|
+
try{
|
|
440
|
+
const stored = localStorage.getItem('ws_chat_alias');
|
|
441
|
+
if(stored){
|
|
442
|
+
const el = document.getElementById('chat_alias');
|
|
443
|
+
if(el) el.value = stored;
|
|
444
|
+
}
|
|
445
|
+
}catch(e){}
|
|
446
|
+
})();
|
|
447
|
+
|
|
448
|
+
document.getElementById('subprotos_preset').onchange = function(){
|
|
449
|
+
const val = this.value;
|
|
450
|
+
if(val){
|
|
451
|
+
document.getElementById('subprotos').value = val;
|
|
452
|
+
}
|
|
453
|
+
};
|
|
454
|
+
|
|
455
|
+
document.getElementById('connect').onclick = function(){
|
|
456
|
+
if(ws && ws.readyState === WebSocket.OPEN) return;
|
|
457
|
+
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
458
|
+
const host = location.host;
|
|
459
|
+
const sublist = document.getElementById('subprotos').value.trim();
|
|
460
|
+
const subprotos = sublist ? sublist.split(',').map(s=>s.trim()).filter(Boolean) : undefined;
|
|
461
|
+
const wsurl = protocol + '//' + host + '/ws/';
|
|
462
|
+
try {
|
|
463
|
+
ws = subprotos && subprotos.length ? new WebSocket(wsurl, subprotos) : new WebSocket(wsurl);
|
|
464
|
+
} catch(e) {
|
|
465
|
+
addMsg('Error creando WebSocket: '+e);
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
ws.binaryType = 'arraybuffer';
|
|
469
|
+
ws.onopen = () => {
|
|
470
|
+
document.getElementById('wsstatus').innerText = 'Estado: abierto';
|
|
471
|
+
addMsg('Conectado (protocol='+ws.protocol+')');
|
|
472
|
+
};
|
|
473
|
+
ws.onmessage = (ev) => {
|
|
474
|
+
if(ev.data instanceof ArrayBuffer){
|
|
475
|
+
const a = new Uint8Array(ev.data);
|
|
476
|
+
addMsg('Recibido BINARIO len='+a.length+' bytes: '+Array.from(a).slice(0,20).join(','));
|
|
477
|
+
} else {
|
|
478
|
+
let prefix = 'Recibido: ';
|
|
479
|
+
let txt = ev.data;
|
|
480
|
+
try{
|
|
481
|
+
const jd = JSON.parse(txt);
|
|
482
|
+
if(jd && jd.type === 'welcome' && jd.client_id){
|
|
483
|
+
document.getElementById('this_client_id').textContent = jd.client_id;
|
|
484
|
+
prefix = 'Recibido (welcome JSON): ';
|
|
485
|
+
}
|
|
486
|
+
}catch(e){}
|
|
487
|
+
addMsg(prefix + txt);
|
|
488
|
+
}
|
|
489
|
+
};
|
|
490
|
+
ws.onclose = (e) => {
|
|
491
|
+
document.getElementById('wsstatus').innerText = 'Estado: cerrado';
|
|
492
|
+
addMsg('Cerrado code='+e.code+' reason='+e.reason);
|
|
493
|
+
};
|
|
494
|
+
ws.onerror = (e) => { addMsg('Error WS: ' + e); };
|
|
495
|
+
};
|
|
496
|
+
|
|
497
|
+
document.getElementById('disconnect').onclick = function(){
|
|
498
|
+
if(ws) ws.close();
|
|
499
|
+
};
|
|
500
|
+
|
|
501
|
+
document.getElementById('sendtext').onclick = function(){
|
|
502
|
+
if(!ws || ws.readyState !== WebSocket.OPEN){
|
|
503
|
+
addMsg('WS no conectado');
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
const m = document.getElementById('textmsg').value || ('Hola desde cliente @ '+new Date().toISOString());
|
|
507
|
+
ws.send(m);
|
|
508
|
+
addMsg('Enviado texto: '+m);
|
|
509
|
+
};
|
|
510
|
+
|
|
511
|
+
document.getElementById('sendbinary').onclick = function(){
|
|
512
|
+
if(!ws || ws.readyState !== WebSocket.OPEN){
|
|
513
|
+
addMsg('WS no conectado');
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
const arr = new Uint8Array(10);
|
|
517
|
+
for(let i=0;i<10;i++) arr[i]=i;
|
|
518
|
+
ws.send(arr.buffer);
|
|
519
|
+
addMsg('Enviado binario 10 bytes');
|
|
520
|
+
};
|
|
521
|
+
|
|
522
|
+
document.getElementById('sendfragmented').onclick = function(){
|
|
523
|
+
if(!ws || ws.readyState !== WebSocket.OPEN){
|
|
524
|
+
addMsg('WS no conectado');
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
const parts = ['parte1-','parte2-','parte3-','(fin)'];
|
|
528
|
+
for(let p of parts){
|
|
529
|
+
ws.send(p);
|
|
530
|
+
}
|
|
531
|
+
addMsg('Enviado (simulado) fragmentado como múltiples mensajes consecutivos');
|
|
532
|
+
};
|
|
533
|
+
|
|
534
|
+
async function callApi(path, method='GET', body=null, onOk){
|
|
535
|
+
const opts = { method, headers: {} };
|
|
536
|
+
if(body){
|
|
537
|
+
opts.body = JSON.stringify(body);
|
|
538
|
+
opts.headers['Content-Type']='application/json';
|
|
539
|
+
}
|
|
540
|
+
const r = await fetch(path, opts);
|
|
541
|
+
const t = await r.text();
|
|
542
|
+
let parsed = null;
|
|
543
|
+
try{ parsed = JSON.parse(t); }catch(e){}
|
|
544
|
+
if(r.ok && typeof onOk === 'function'){
|
|
545
|
+
try{ onOk(parsed, t, r); }catch(e){}
|
|
546
|
+
}
|
|
547
|
+
addMsg('/api -> ' + r.status + ' ' + r.statusText + ' : ' + t);
|
|
548
|
+
return t;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
document.getElementById('listclients').onclick = () =>
|
|
552
|
+
callApi('/api/ws/clients', 'GET', null, (jsonList) => {
|
|
553
|
+
refreshClientsSelect(jsonList);
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
document.getElementById('server_broadcast').onclick = () => {
|
|
557
|
+
const text = prompt('Texto para broadcast por servidor:', 'Broadcast desde servidor @ '+new Date().toISOString());
|
|
558
|
+
if(text) callApi('/api/ws/broadcast', 'POST', {type:'text', message:text});
|
|
559
|
+
};
|
|
560
|
+
|
|
561
|
+
document.getElementById('server_ping').onclick = () =>
|
|
562
|
+
callApi('/api/ws/ping', 'POST', {});
|
|
563
|
+
|
|
564
|
+
document.getElementById('server_close').onclick = () => {
|
|
565
|
+
const sel = document.getElementById('close_client_select');
|
|
566
|
+
let id = sel && sel.value ? sel.value : null;
|
|
567
|
+
if(!id){
|
|
568
|
+
const manual = document.getElementById('close_client_id').value.trim();
|
|
569
|
+
if(manual) id = manual;
|
|
570
|
+
}
|
|
571
|
+
const code = parseInt(document.getElementById('close_code').value) || 1000;
|
|
572
|
+
const reason = document.getElementById('close_reason').value || '';
|
|
573
|
+
callApi('/api/ws/close', 'POST', {client_id: id, code: code, reason: reason});
|
|
574
|
+
};
|
|
575
|
+
|
|
576
|
+
document.getElementById('server_chat_list').onclick = () =>
|
|
577
|
+
callApi('/api/chat/messages?limit=20', 'GET', null);
|
|
578
|
+
|
|
579
|
+
document.getElementById('server_chat_clear').onclick = () =>
|
|
580
|
+
callApi('/api/chat/clear', 'POST', {});
|
|
581
|
+
|
|
582
|
+
// ----- Chat UI -----
|
|
583
|
+
document.getElementById('chat_save_alias').onclick = function(){
|
|
584
|
+
const alias = getChatAlias();
|
|
585
|
+
try{
|
|
586
|
+
localStorage.setItem('ws_chat_alias', alias);
|
|
587
|
+
}catch(e){}
|
|
588
|
+
addMsg('[CHAT] Alias establecido: ' + alias);
|
|
589
|
+
};
|
|
590
|
+
|
|
591
|
+
document.getElementById('chat_clear').onclick = function(){
|
|
592
|
+
const ul = document.getElementById('messages');
|
|
593
|
+
ul.innerHTML = '';
|
|
594
|
+
};
|
|
595
|
+
|
|
596
|
+
document.getElementById('chat_send').onclick = function(){
|
|
597
|
+
const txtEl = document.getElementById('chat_text');
|
|
598
|
+
const raw = (txtEl.value || '').trim();
|
|
599
|
+
if(!raw){
|
|
600
|
+
addMsg('[CHAT] Mensaje vacío, no se envía.');
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
const alias = getChatAlias();
|
|
604
|
+
const modeSel = document.getElementById('chat_mode');
|
|
605
|
+
const mode = modeSel ? modeSel.value : 'local';
|
|
606
|
+
const full = '[' + alias + '] ' + raw;
|
|
607
|
+
|
|
608
|
+
if(mode === 'local'){
|
|
609
|
+
if(!ws || ws.readyState !== WebSocket.OPEN){
|
|
610
|
+
addMsg('[CHAT] WS no conectado (modo local).');
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
ws.send(full);
|
|
614
|
+
addMsg('[CHAT->WS] ' + full);
|
|
615
|
+
} else if(mode === 'broadcast'){
|
|
616
|
+
callApi('/api/ws/broadcast', 'POST', {type:'text', message: full});
|
|
617
|
+
addMsg('[CHAT->API broadcast] ' + full);
|
|
618
|
+
} else if(mode === 'ping'){
|
|
619
|
+
callApi('/api/ws/ping', 'POST', {payload: full});
|
|
620
|
+
addMsg('[CHAT->API ping] ' + full);
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
txtEl.value = '';
|
|
624
|
+
txtEl.focus();
|
|
625
|
+
};
|
|
626
|
+
|
|
627
|
+
document.getElementById('chat_text').addEventListener('keydown', function(ev){
|
|
628
|
+
if(ev.key === 'Enter'){
|
|
629
|
+
ev.preventDefault();
|
|
630
|
+
document.getElementById('chat_send').click();
|
|
631
|
+
}
|
|
632
|
+
});
|
|
633
|
+
</script>
|
|
634
|
+
</body>
|
|
635
|
+
</html>
|
|
636
|
+
""".encode('utf-8')
|
|
637
|
+
|
|
638
|
+
# ============================================================
|
|
639
|
+
# ORM SQLite (solo sqlite3 + threading)
|
|
640
|
+
# ============================================================
|
|
641
|
+
|
|
642
|
+
class Field:
|
|
643
|
+
def __init__(self, column_type, primary_key=False, unique=False,
|
|
644
|
+
null=True, default=None, index=False):
|
|
645
|
+
self.column_type = column_type
|
|
646
|
+
self.primary_key = primary_key
|
|
647
|
+
self.unique = unique
|
|
648
|
+
self.null = null
|
|
649
|
+
self.default = default
|
|
650
|
+
self.index = index
|
|
651
|
+
self.name = None # se rellena en el metaclass
|
|
652
|
+
|
|
653
|
+
def _sql_literal(self, value):
|
|
654
|
+
if value is None:
|
|
655
|
+
return "NULL"
|
|
656
|
+
if isinstance(value, bool):
|
|
657
|
+
return "1" if value else "0"
|
|
658
|
+
if isinstance(value, (int, float)):
|
|
659
|
+
return str(value)
|
|
660
|
+
s = str(value).replace("'", "''")
|
|
661
|
+
return "'" + s + "'"
|
|
662
|
+
|
|
663
|
+
def ddl_fragment(self):
|
|
664
|
+
parts = [self.name, self.column_type]
|
|
665
|
+
if self.primary_key:
|
|
666
|
+
parts.append("PRIMARY KEY")
|
|
667
|
+
if self.unique:
|
|
668
|
+
parts.append("UNIQUE")
|
|
669
|
+
if not self.null and not self.primary_key:
|
|
670
|
+
parts.append("NOT NULL")
|
|
671
|
+
if self.default is not None and not self.primary_key:
|
|
672
|
+
parts.append("DEFAULT")
|
|
673
|
+
parts.append(self._sql_literal(self.default))
|
|
674
|
+
return " ".join(parts)
|
|
675
|
+
|
|
676
|
+
class IntegerField(Field):
|
|
677
|
+
def __init__(self, primary_key=False, unique=False, null=True,
|
|
678
|
+
default=None, index=False, auto_increment=False):
|
|
679
|
+
super().__init__("INTEGER", primary_key=primary_key, unique=unique,
|
|
680
|
+
null=null, default=default, index=index)
|
|
681
|
+
self.auto_increment = auto_increment
|
|
682
|
+
|
|
683
|
+
def ddl_fragment(self):
|
|
684
|
+
parts = [self.name, self.column_type]
|
|
685
|
+
if self.primary_key:
|
|
686
|
+
parts.append("PRIMARY KEY")
|
|
687
|
+
if self.auto_increment and self.primary_key:
|
|
688
|
+
parts.append("AUTOINCREMENT")
|
|
689
|
+
if self.unique and not self.primary_key:
|
|
690
|
+
parts.append("UNIQUE")
|
|
691
|
+
if not self.null and not self.primary_key:
|
|
692
|
+
parts.append("NOT NULL")
|
|
693
|
+
if self.default is not None and not self.primary_key:
|
|
694
|
+
parts.append("DEFAULT")
|
|
695
|
+
parts.append(self._sql_literal(self.default))
|
|
696
|
+
return " ".join(parts)
|
|
697
|
+
|
|
698
|
+
class TextField(Field):
|
|
699
|
+
def __init__(self, primary_key=False, unique=False, null=True,
|
|
700
|
+
default=None, index=False):
|
|
701
|
+
super().__init__("TEXT", primary_key=primary_key, unique=unique,
|
|
702
|
+
null=null, default=default, index=index)
|
|
703
|
+
|
|
704
|
+
class RealField(Field):
|
|
705
|
+
def __init__(self, primary_key=False, unique=False, null=True,
|
|
706
|
+
default=None, index=False):
|
|
707
|
+
super().__init__("REAL", primary_key=primary_key, unique=unique,
|
|
708
|
+
null=null, default=default, index=index)
|
|
709
|
+
|
|
710
|
+
class BlobField(Field):
|
|
711
|
+
def __init__(self, primary_key=False, unique=False, null=True,
|
|
712
|
+
default=None, index=False):
|
|
713
|
+
super().__init__("BLOB", primary_key=primary_key, unique=unique,
|
|
714
|
+
null=null, default=default, index=index)
|
|
715
|
+
|
|
716
|
+
class BooleanField(IntegerField):
|
|
717
|
+
def __init__(self, primary_key=False, unique=False, null=True,
|
|
718
|
+
default=None, index=False):
|
|
719
|
+
super().__init__(primary_key=primary_key, unique=unique,
|
|
720
|
+
null=null, default=default, index=index)
|
|
721
|
+
|
|
722
|
+
class ModelMeta(type):
|
|
723
|
+
def __new__(mcls, name, bases, attrs):
|
|
724
|
+
if name == "Model":
|
|
725
|
+
return super().__new__(mcls, name, bases, attrs)
|
|
726
|
+
|
|
727
|
+
fields = {}
|
|
728
|
+
for key, value in list(attrs.items()):
|
|
729
|
+
if isinstance(value, Field):
|
|
730
|
+
value.name = key
|
|
731
|
+
fields[key] = value
|
|
732
|
+
|
|
733
|
+
tablename = attrs.get("__tablename__") or name.lower()
|
|
734
|
+
pk_name = None
|
|
735
|
+
for fname, field in fields.items():
|
|
736
|
+
if field.primary_key:
|
|
737
|
+
pk_name = fname
|
|
738
|
+
break
|
|
739
|
+
|
|
740
|
+
attrs["_meta"] = {
|
|
741
|
+
"table": tablename,
|
|
742
|
+
"fields": fields,
|
|
743
|
+
"pk_name": pk_name,
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
return super().__new__(mcls, name, bases, attrs)
|
|
747
|
+
|
|
748
|
+
class Database:
|
|
749
|
+
def __init__(self, path=":memory:", shared_cache=False,
|
|
750
|
+
timeout=10.0, pragmas=None):
|
|
751
|
+
"""
|
|
752
|
+
path=":memory:" -> BD en memoria privada
|
|
753
|
+
shared_cache=True -> usa "file::memory:?cache=shared" (uri=True)
|
|
754
|
+
timeout=10.0 -> busy timeout
|
|
755
|
+
pragmas=dict() -> PRAGMAs extra
|
|
756
|
+
"""
|
|
757
|
+
self._lock = threading.RLock()
|
|
758
|
+
self._tx_depth = threading.local()
|
|
759
|
+
|
|
760
|
+
if shared_cache:
|
|
761
|
+
if path == ":memory:":
|
|
762
|
+
dsn = "file::memory:?cache=shared"
|
|
763
|
+
else:
|
|
764
|
+
dsn = path
|
|
765
|
+
self._conn = sqlite3.connect(
|
|
766
|
+
dsn,
|
|
767
|
+
check_same_thread=False,
|
|
768
|
+
timeout=timeout,
|
|
769
|
+
uri=True,
|
|
770
|
+
)
|
|
771
|
+
else:
|
|
772
|
+
self._conn = sqlite3.connect(
|
|
773
|
+
path,
|
|
774
|
+
check_same_thread=False,
|
|
775
|
+
timeout=timeout,
|
|
776
|
+
)
|
|
777
|
+
|
|
778
|
+
self._conn.row_factory = sqlite3.Row
|
|
779
|
+
|
|
780
|
+
base_pragmas = {
|
|
781
|
+
"journal_mode": "WAL",
|
|
782
|
+
"synchronous": "NORMAL",
|
|
783
|
+
"foreign_keys": 1,
|
|
784
|
+
"busy_timeout": int(timeout * 1000),
|
|
785
|
+
}
|
|
786
|
+
if pragmas:
|
|
787
|
+
base_pragmas.update(pragmas)
|
|
788
|
+
|
|
789
|
+
for k, v in base_pragmas.items():
|
|
790
|
+
self.set_pragma(k, v)
|
|
791
|
+
|
|
792
|
+
def set_pragma(self, name, value):
|
|
793
|
+
sql = "PRAGMA %s=%s" % (name, value)
|
|
794
|
+
with self._lock:
|
|
795
|
+
print("[DB]", sql)
|
|
796
|
+
self._conn.execute(sql)
|
|
797
|
+
|
|
798
|
+
def get_pragma(self, name):
|
|
799
|
+
sql = "PRAGMA %s" % name
|
|
800
|
+
with self._lock:
|
|
801
|
+
print("[DB]", sql)
|
|
802
|
+
cur = self._conn.execute(sql)
|
|
803
|
+
row = cur.fetchone()
|
|
804
|
+
if row is None:
|
|
805
|
+
return None
|
|
806
|
+
return row[0]
|
|
807
|
+
|
|
808
|
+
def _get_tx_depth(self):
|
|
809
|
+
return getattr(self._tx_depth, "value", 0)
|
|
810
|
+
|
|
811
|
+
def _set_tx_depth(self, v):
|
|
812
|
+
self._tx_depth.value = v
|
|
813
|
+
|
|
814
|
+
def in_transaction(self):
|
|
815
|
+
return self._get_tx_depth() > 0
|
|
816
|
+
|
|
817
|
+
def execute(self, sql, params=None):
|
|
818
|
+
if params is None:
|
|
819
|
+
params = ()
|
|
820
|
+
sql_trim = sql.strip()
|
|
821
|
+
first = sql_trim.split()[0].upper() if sql_trim else ""
|
|
822
|
+
is_write = first in (
|
|
823
|
+
"INSERT", "UPDATE", "DELETE", "REPLACE",
|
|
824
|
+
"CREATE", "DROP", "ALTER",
|
|
825
|
+
)
|
|
826
|
+
with self._lock:
|
|
827
|
+
print("[DB] SQL:", sql_trim, "params=", params)
|
|
828
|
+
cur = self._conn.execute(sql, params)
|
|
829
|
+
if is_write and not self.in_transaction():
|
|
830
|
+
self._conn.commit()
|
|
831
|
+
return cur
|
|
832
|
+
|
|
833
|
+
def executemany(self, sql, seq_of_params):
|
|
834
|
+
sql_trim = sql.strip()
|
|
835
|
+
first = sql_trim.split()[0].upper() if sql_trim else ""
|
|
836
|
+
is_write = first in ("INSERT", "UPDATE", "DELETE", "REPLACE")
|
|
837
|
+
with self._lock:
|
|
838
|
+
count = 0
|
|
839
|
+
for p in seq_of_params:
|
|
840
|
+
print("[DB] SQL many:", sql_trim, "params=", p)
|
|
841
|
+
self._conn.execute(sql, p)
|
|
842
|
+
count += 1
|
|
843
|
+
if is_write and not self.in_transaction():
|
|
844
|
+
self._conn.commit()
|
|
845
|
+
return count
|
|
846
|
+
|
|
847
|
+
def transaction(self):
|
|
848
|
+
return Transaction(self)
|
|
849
|
+
|
|
850
|
+
class Transaction:
|
|
851
|
+
def __init__(self, db):
|
|
852
|
+
self.db = db
|
|
853
|
+
self._sp_name = None
|
|
854
|
+
self._entered = False
|
|
855
|
+
|
|
856
|
+
def __enter__(self):
|
|
857
|
+
depth = self.db._get_tx_depth()
|
|
858
|
+
if depth == 0:
|
|
859
|
+
with self.db._lock:
|
|
860
|
+
print("[DB] BEGIN")
|
|
861
|
+
self.db._conn.execute("BEGIN")
|
|
862
|
+
self.db._set_tx_depth(1)
|
|
863
|
+
else:
|
|
864
|
+
self._sp_name = "sp_%d" % depth
|
|
865
|
+
with self.db._lock:
|
|
866
|
+
print("[DB] SAVEPOINT", self._sp_name)
|
|
867
|
+
self.db._conn.execute("SAVEPOINT %s" % self._sp_name)
|
|
868
|
+
self.db._set_tx_depth(depth + 1)
|
|
869
|
+
self._entered = True
|
|
870
|
+
return self
|
|
871
|
+
|
|
872
|
+
def __exit__(self, exc_type, exc, tb):
|
|
873
|
+
if not self._entered:
|
|
874
|
+
return False
|
|
875
|
+
|
|
876
|
+
depth = self.db._get_tx_depth()
|
|
877
|
+
try:
|
|
878
|
+
if exc_type is not None:
|
|
879
|
+
if self._sp_name is None:
|
|
880
|
+
with self.db._lock:
|
|
881
|
+
print("[DB] ROLLBACK")
|
|
882
|
+
self.db._conn.rollback()
|
|
883
|
+
self.db._set_tx_depth(0)
|
|
884
|
+
else:
|
|
885
|
+
with self.db._lock:
|
|
886
|
+
print("[DB] ROLLBACK TO SAVEPOINT", self._sp_name)
|
|
887
|
+
self.db._conn.execute("ROLLBACK TO SAVEPOINT %s" % self._sp_name)
|
|
888
|
+
print("[DB] RELEASE SAVEPOINT", self._sp_name)
|
|
889
|
+
self.db._conn.execute("RELEASE SAVEPOINT %s" % self._sp_name)
|
|
890
|
+
self.db._set_tx_depth(depth - 1)
|
|
891
|
+
else:
|
|
892
|
+
if self._sp_name is None:
|
|
893
|
+
with self.db._lock:
|
|
894
|
+
print("[DB] COMMIT")
|
|
895
|
+
self.db._conn.commit()
|
|
896
|
+
self.db._set_tx_depth(0)
|
|
897
|
+
else:
|
|
898
|
+
with self.db._lock:
|
|
899
|
+
print("[DB] RELEASE SAVEPOINT", self._sp_name)
|
|
900
|
+
self.db._conn.execute("RELEASE SAVEPOINT %s" % self._sp_name)
|
|
901
|
+
self.db._set_tx_depth(depth - 1)
|
|
902
|
+
finally:
|
|
903
|
+
self._entered = False
|
|
904
|
+
return False
|
|
905
|
+
|
|
906
|
+
class QuerySet:
|
|
907
|
+
def __init__(self, db, model, where=None, params=None,
|
|
908
|
+
order_by=None, limit=None, offset=None):
|
|
909
|
+
self.db = db
|
|
910
|
+
self.model = model
|
|
911
|
+
self.where = where
|
|
912
|
+
self.params = params or []
|
|
913
|
+
self.order_by_clause = order_by
|
|
914
|
+
self.limit_val = limit
|
|
915
|
+
self.offset_val = offset
|
|
916
|
+
|
|
917
|
+
def _clone(self, **overrides):
|
|
918
|
+
kwargs = dict(
|
|
919
|
+
db=self.db,
|
|
920
|
+
model=self.model,
|
|
921
|
+
where=self.where,
|
|
922
|
+
params=list(self.params),
|
|
923
|
+
order_by=self.order_by_clause,
|
|
924
|
+
limit=self.limit_val,
|
|
925
|
+
offset=self.offset_val,
|
|
926
|
+
)
|
|
927
|
+
for k, v in overrides.items():
|
|
928
|
+
kwargs[k] = v
|
|
929
|
+
return QuerySet(**kwargs)
|
|
930
|
+
|
|
931
|
+
def where_raw(self, clause, *params):
|
|
932
|
+
if self.where:
|
|
933
|
+
new_where = "(" + self.where + ") AND (" + clause + ")"
|
|
934
|
+
new_params = list(self.params) + list(params)
|
|
935
|
+
else:
|
|
936
|
+
new_where = clause
|
|
937
|
+
new_params = list(params)
|
|
938
|
+
return self._clone(where=new_where, params=new_params)
|
|
939
|
+
|
|
940
|
+
def filter(self, **kwargs):
|
|
941
|
+
clauses = []
|
|
942
|
+
params = []
|
|
943
|
+
for key, value in kwargs.items():
|
|
944
|
+
if "__" in key:
|
|
945
|
+
field_name, op = key.split("__", 1)
|
|
946
|
+
else:
|
|
947
|
+
field_name, op = key, "eq"
|
|
948
|
+
|
|
949
|
+
column = field_name
|
|
950
|
+
if op == "eq":
|
|
951
|
+
clauses.append(column + " = ?")
|
|
952
|
+
params.append(value)
|
|
953
|
+
elif op == "gt":
|
|
954
|
+
clauses.append(column + " > ?")
|
|
955
|
+
params.append(value)
|
|
956
|
+
elif op == "lt":
|
|
957
|
+
clauses.append(column + " < ?")
|
|
958
|
+
params.append(value)
|
|
959
|
+
elif op == "gte":
|
|
960
|
+
clauses.append(column + " >= ?")
|
|
961
|
+
params.append(value)
|
|
962
|
+
elif op == "lte":
|
|
963
|
+
clauses.append(column + " <= ?")
|
|
964
|
+
params.append(value)
|
|
965
|
+
elif op == "ne":
|
|
966
|
+
clauses.append(column + " != ?")
|
|
967
|
+
params.append(value)
|
|
968
|
+
elif op == "like":
|
|
969
|
+
clauses.append(column + " LIKE ?")
|
|
970
|
+
params.append(value)
|
|
971
|
+
elif op == "in":
|
|
972
|
+
if not value:
|
|
973
|
+
clauses.append("1=0")
|
|
974
|
+
else:
|
|
975
|
+
placeholders = ",".join("?" for _ in value)
|
|
976
|
+
clauses.append(column + " IN (" + placeholders + ")")
|
|
977
|
+
for v in value:
|
|
978
|
+
params.append(v)
|
|
979
|
+
else:
|
|
980
|
+
raise ValueError("Operador no soportado: " + op)
|
|
981
|
+
|
|
982
|
+
if clauses:
|
|
983
|
+
clause = " AND ".join(clauses)
|
|
984
|
+
return self.where_raw(clause, *params)
|
|
985
|
+
else:
|
|
986
|
+
return self
|
|
987
|
+
|
|
988
|
+
def order_by(self, clause):
|
|
989
|
+
return self._clone(order_by=clause)
|
|
990
|
+
|
|
991
|
+
def limit(self, n, offset=None):
|
|
992
|
+
return self._clone(limit=n, offset=offset if offset is not None else self.offset_val)
|
|
993
|
+
|
|
994
|
+
def _build_select(self):
|
|
995
|
+
fields = list(self.model._meta["fields"].keys())
|
|
996
|
+
columns_sql = ", ".join(fields)
|
|
997
|
+
table = self.model._meta["table"]
|
|
998
|
+
sql = "SELECT " + columns_sql + " FROM " + table
|
|
999
|
+
params = list(self.params)
|
|
1000
|
+
if self.where:
|
|
1001
|
+
sql += " WHERE " + self.where
|
|
1002
|
+
if self.order_by_clause:
|
|
1003
|
+
sql += " ORDER BY " + self.order_by_clause
|
|
1004
|
+
if self.limit_val is not None:
|
|
1005
|
+
sql += " LIMIT ?"
|
|
1006
|
+
params.append(self.limit_val)
|
|
1007
|
+
if self.offset_val is not None:
|
|
1008
|
+
sql += " OFFSET ?"
|
|
1009
|
+
params.append(self.offset_val)
|
|
1010
|
+
return sql, params
|
|
1011
|
+
|
|
1012
|
+
def all(self):
|
|
1013
|
+
sql, params = self._build_select()
|
|
1014
|
+
cur = self.db.execute(sql, params)
|
|
1015
|
+
rows = cur.fetchall()
|
|
1016
|
+
objs = []
|
|
1017
|
+
for row in rows:
|
|
1018
|
+
objs.append(self.model.from_row(row))
|
|
1019
|
+
return objs
|
|
1020
|
+
|
|
1021
|
+
def first(self):
|
|
1022
|
+
qs = self.limit(1)
|
|
1023
|
+
sql, params = qs._build_select()
|
|
1024
|
+
cur = self.db.execute(sql, params)
|
|
1025
|
+
row = cur.fetchone()
|
|
1026
|
+
if not row:
|
|
1027
|
+
return None
|
|
1028
|
+
return self.model.from_row(row)
|
|
1029
|
+
|
|
1030
|
+
def get(self, **kwargs):
|
|
1031
|
+
qs = self.filter(**kwargs).limit(2)
|
|
1032
|
+
sql, params = qs._build_select()
|
|
1033
|
+
cur = self.db.execute(sql, params)
|
|
1034
|
+
rows = cur.fetchall()
|
|
1035
|
+
if not rows:
|
|
1036
|
+
raise LookupError("No se encontró ningún registro")
|
|
1037
|
+
if len(rows) > 1:
|
|
1038
|
+
raise LookupError("Se encontraron múltiples registros para .get()")
|
|
1039
|
+
return self.model.from_row(rows[0])
|
|
1040
|
+
|
|
1041
|
+
def count(self):
|
|
1042
|
+
table = self.model._meta["table"]
|
|
1043
|
+
sql = "SELECT COUNT(*) AS c FROM " + table
|
|
1044
|
+
params = list(self.params)
|
|
1045
|
+
if self.where:
|
|
1046
|
+
sql += " WHERE " + self.where
|
|
1047
|
+
cur = self.db.execute(sql, params)
|
|
1048
|
+
row = cur.fetchone()
|
|
1049
|
+
return row["c"]
|
|
1050
|
+
|
|
1051
|
+
def delete(self):
|
|
1052
|
+
table = self.model._meta["table"]
|
|
1053
|
+
sql = "DELETE FROM " + table
|
|
1054
|
+
params = list(self.params)
|
|
1055
|
+
if self.where:
|
|
1056
|
+
sql += " WHERE " + self.where
|
|
1057
|
+
cur = self.db.execute(sql, params)
|
|
1058
|
+
return cur.rowcount
|
|
1059
|
+
|
|
1060
|
+
def update(self, **kwargs):
|
|
1061
|
+
if not kwargs:
|
|
1062
|
+
return 0
|
|
1063
|
+
table = self.model._meta["table"]
|
|
1064
|
+
set_clauses = []
|
|
1065
|
+
params = []
|
|
1066
|
+
for k, v in kwargs.items():
|
|
1067
|
+
set_clauses.append(k + " = ?")
|
|
1068
|
+
params.append(v)
|
|
1069
|
+
sql = "UPDATE " + table + " SET " + ", ".join(set_clauses)
|
|
1070
|
+
if self.where:
|
|
1071
|
+
sql += " WHERE " + self.where
|
|
1072
|
+
params.extend(self.params)
|
|
1073
|
+
cur = self.db.execute(sql, params)
|
|
1074
|
+
return cur.rowcount
|
|
1075
|
+
|
|
1076
|
+
def create(self, **kwargs):
|
|
1077
|
+
obj = self.model(**kwargs)
|
|
1078
|
+
obj.save(self.db)
|
|
1079
|
+
return obj
|
|
1080
|
+
|
|
1081
|
+
class Model(metaclass=ModelMeta):
|
|
1082
|
+
__tablename__ = None
|
|
1083
|
+
|
|
1084
|
+
def __init__(self, **kwargs):
|
|
1085
|
+
fields = self._meta["fields"]
|
|
1086
|
+
for name, field in fields.items():
|
|
1087
|
+
if name in kwargs:
|
|
1088
|
+
value = kwargs[name]
|
|
1089
|
+
else:
|
|
1090
|
+
if callable(field.default):
|
|
1091
|
+
value = field.default()
|
|
1092
|
+
else:
|
|
1093
|
+
value = field.default
|
|
1094
|
+
setattr(self, name, value)
|
|
1095
|
+
|
|
1096
|
+
@classmethod
|
|
1097
|
+
def create_table(cls, db, if_not_exists=True):
|
|
1098
|
+
fields = cls._meta["fields"]
|
|
1099
|
+
table = cls._meta["table"]
|
|
1100
|
+
if not fields:
|
|
1101
|
+
raise RuntimeError("Modelo sin campos: " + cls.__name__)
|
|
1102
|
+
|
|
1103
|
+
column_ddls = []
|
|
1104
|
+
index_ddls = []
|
|
1105
|
+
for name, field in fields.items():
|
|
1106
|
+
field.name = name
|
|
1107
|
+
column_ddls.append(field.ddl_fragment())
|
|
1108
|
+
if field.index and not field.primary_key:
|
|
1109
|
+
idx_name = "idx_%s_%s" % (table, name)
|
|
1110
|
+
unique = "UNIQUE " if field.unique else ""
|
|
1111
|
+
idx_sql = "CREATE " + unique + "INDEX IF NOT EXISTS " + idx_name
|
|
1112
|
+
idx_sql += " ON " + table + " (" + name + ");"
|
|
1113
|
+
index_ddls.append(idx_sql)
|
|
1114
|
+
|
|
1115
|
+
ine = "IF NOT EXISTS " if if_not_exists else ""
|
|
1116
|
+
sql = "CREATE TABLE " + ine + table + " (\n "
|
|
1117
|
+
sql += ",\n ".join(column_ddls) + "\n);"
|
|
1118
|
+
db.execute(sql)
|
|
1119
|
+
for idx_sql in index_ddls:
|
|
1120
|
+
db.execute(idx_sql)
|
|
1121
|
+
|
|
1122
|
+
@classmethod
|
|
1123
|
+
def drop_table(cls, db, if_exists=True):
|
|
1124
|
+
table = cls._meta["table"]
|
|
1125
|
+
ie = "IF EXISTS " if if_exists else ""
|
|
1126
|
+
sql = "DROP TABLE " + ie + table + ";"
|
|
1127
|
+
db.execute(sql)
|
|
1128
|
+
|
|
1129
|
+
@classmethod
|
|
1130
|
+
def objects(cls, db):
|
|
1131
|
+
return QuerySet(db, cls)
|
|
1132
|
+
|
|
1133
|
+
@classmethod
|
|
1134
|
+
def from_row(cls, row):
|
|
1135
|
+
if row is None:
|
|
1136
|
+
return None
|
|
1137
|
+
data = {}
|
|
1138
|
+
for name in cls._meta["fields"].keys():
|
|
1139
|
+
data[name] = row[name]
|
|
1140
|
+
return cls(**data)
|
|
1141
|
+
|
|
1142
|
+
@classmethod
|
|
1143
|
+
def raw(cls, db, sql, params=None):
|
|
1144
|
+
if params is None:
|
|
1145
|
+
params = ()
|
|
1146
|
+
cur = db.execute(sql, params)
|
|
1147
|
+
rows = cur.fetchall()
|
|
1148
|
+
objs = []
|
|
1149
|
+
for row in rows:
|
|
1150
|
+
data = {}
|
|
1151
|
+
for name in cls._meta["fields"].keys():
|
|
1152
|
+
if name in row.keys():
|
|
1153
|
+
data[name] = row[name]
|
|
1154
|
+
else:
|
|
1155
|
+
data[name] = None
|
|
1156
|
+
objs.append(cls(**data))
|
|
1157
|
+
return objs
|
|
1158
|
+
|
|
1159
|
+
def pk_value(self):
|
|
1160
|
+
pk_name = self._meta["pk_name"]
|
|
1161
|
+
if pk_name is None:
|
|
1162
|
+
return None
|
|
1163
|
+
return getattr(self, pk_name, None)
|
|
1164
|
+
|
|
1165
|
+
def save(self, db):
|
|
1166
|
+
fields = self._meta["fields"]
|
|
1167
|
+
table = self._meta["table"]
|
|
1168
|
+
pk_name = self._meta["pk_name"]
|
|
1169
|
+
pk_val = None
|
|
1170
|
+
if pk_name:
|
|
1171
|
+
pk_val = getattr(self, pk_name, None)
|
|
1172
|
+
|
|
1173
|
+
if pk_name and pk_val is not None:
|
|
1174
|
+
cols = []
|
|
1175
|
+
params = []
|
|
1176
|
+
for name, field in fields.items():
|
|
1177
|
+
if name == pk_name:
|
|
1178
|
+
continue
|
|
1179
|
+
cols.append(name + "=?")
|
|
1180
|
+
params.append(getattr(self, name))
|
|
1181
|
+
params.append(pk_val)
|
|
1182
|
+
sql = "UPDATE " + table + " SET " + ", ".join(cols)
|
|
1183
|
+
sql += " WHERE " + pk_name + "=?"
|
|
1184
|
+
cur = db.execute(sql, params)
|
|
1185
|
+
return cur.rowcount
|
|
1186
|
+
else:
|
|
1187
|
+
cols = []
|
|
1188
|
+
placeholders = []
|
|
1189
|
+
params = []
|
|
1190
|
+
for name, field in fields.items():
|
|
1191
|
+
if field.primary_key and isinstance(field, IntegerField):
|
|
1192
|
+
continue
|
|
1193
|
+
cols.append(name)
|
|
1194
|
+
placeholders.append("?")
|
|
1195
|
+
params.append(getattr(self, name))
|
|
1196
|
+
sql = "INSERT INTO " + table + " (" + ", ".join(cols) + ")"
|
|
1197
|
+
sql += " VALUES (" + ", ".join(placeholders) + ")"
|
|
1198
|
+
cur = db.execute(sql, params)
|
|
1199
|
+
if pk_name and isinstance(fields[pk_name], IntegerField):
|
|
1200
|
+
new_id = cur.lastrowid
|
|
1201
|
+
setattr(self, pk_name, new_id)
|
|
1202
|
+
return 1
|
|
1203
|
+
|
|
1204
|
+
def delete(self, db):
|
|
1205
|
+
table = self._meta["table"]
|
|
1206
|
+
pk_name = self._meta["pk_name"]
|
|
1207
|
+
if not pk_name:
|
|
1208
|
+
raise RuntimeError("Modelo sin clave primaria, delete() no soportado")
|
|
1209
|
+
pk_val = getattr(self, pk_name, None)
|
|
1210
|
+
if pk_val is None:
|
|
1211
|
+
return 0
|
|
1212
|
+
sql = "DELETE FROM " + table + " WHERE " + pk_name + "=?"
|
|
1213
|
+
cur = db.execute(sql, (pk_val,))
|
|
1214
|
+
return cur.rowcount
|
|
1215
|
+
|
|
1216
|
+
def to_dict(self):
|
|
1217
|
+
data = {}
|
|
1218
|
+
for name in self._meta["fields"].keys():
|
|
1219
|
+
data[name] = getattr(self, name)
|
|
1220
|
+
return data
|
|
1221
|
+
|
|
1222
|
+
# ============================================================
|
|
1223
|
+
# Modelo ChatMessage (ejemplo de uso del ORM)
|
|
1224
|
+
# ============================================================
|
|
1225
|
+
|
|
1226
|
+
class ChatMessage(Model):
|
|
1227
|
+
__tablename__ = "chat_messages"
|
|
1228
|
+
id = IntegerField(primary_key=True, auto_increment=True)
|
|
1229
|
+
client_id = TextField(index=True, null=False)
|
|
1230
|
+
alias = TextField(null=False)
|
|
1231
|
+
message = TextField(null=False)
|
|
1232
|
+
created_at = IntegerField(null=False, index=True)
|
|
1233
|
+
|
|
1234
|
+
def parse_chat_line(text):
|
|
1235
|
+
"""
|
|
1236
|
+
Intenta parsear "[alias] mensaje" y devuelve (alias, mensaje).
|
|
1237
|
+
Si no coincide el patrón, alias=None y mensaje=text.
|
|
1238
|
+
"""
|
|
1239
|
+
if text.startswith('['):
|
|
1240
|
+
idx = text.find(']')
|
|
1241
|
+
if idx > 1:
|
|
1242
|
+
alias = text[1:idx].strip()
|
|
1243
|
+
msg = text[idx+1:].lstrip()
|
|
1244
|
+
return alias or 'anon', msg
|
|
1245
|
+
return None, text
|
|
1246
|
+
|
|
1247
|
+
# ============================================================
|
|
1248
|
+
# ClientRegistry (gestiona clientes WS de forma thread-safe)
|
|
1249
|
+
# ============================================================
|
|
1250
|
+
|
|
1251
|
+
class ClientRegistry:
|
|
1252
|
+
def __init__(self):
|
|
1253
|
+
self._clients = {} # client_id -> dict {sock, addr, thread, subprotocol, created}
|
|
1254
|
+
self._lock = threading.Lock()
|
|
1255
|
+
|
|
1256
|
+
def register_client(self, client_id, sock, addr, thread, subprotocol):
|
|
1257
|
+
with self._lock:
|
|
1258
|
+
self._clients[client_id] = {
|
|
1259
|
+
'sock': sock,
|
|
1260
|
+
'addr': addr,
|
|
1261
|
+
'thread': thread,
|
|
1262
|
+
'subprotocol': subprotocol or '',
|
|
1263
|
+
'created': time.time()
|
|
1264
|
+
}
|
|
1265
|
+
try:
|
|
1266
|
+
print(f"[WS] Registrado cliente {client_id} desde {addr} (subprotocol={subprotocol or ''})")
|
|
1267
|
+
except Exception:
|
|
1268
|
+
pass
|
|
1269
|
+
|
|
1270
|
+
def unregister_client(self, client_id):
|
|
1271
|
+
with self._lock:
|
|
1272
|
+
info = self._clients.pop(client_id, None)
|
|
1273
|
+
if info:
|
|
1274
|
+
try:
|
|
1275
|
+
info['sock'].close()
|
|
1276
|
+
except Exception:
|
|
1277
|
+
pass
|
|
1278
|
+
try:
|
|
1279
|
+
print(f"[WS] Cliente {client_id} desconectado")
|
|
1280
|
+
except Exception:
|
|
1281
|
+
pass
|
|
1282
|
+
|
|
1283
|
+
def list_clients_info(self):
|
|
1284
|
+
with self._lock:
|
|
1285
|
+
out = []
|
|
1286
|
+
for cid, info in self._clients.items():
|
|
1287
|
+
out.append({
|
|
1288
|
+
'id': cid,
|
|
1289
|
+
'addr': f"{info['addr'][0]}:{info['addr'][1]}",
|
|
1290
|
+
'subprotocol': info.get('subprotocol',''),
|
|
1291
|
+
'created': info.get('created',0)
|
|
1292
|
+
})
|
|
1293
|
+
print(f"[WS] list_clients_info -> {len(out)} clientes")
|
|
1294
|
+
return out
|
|
1295
|
+
|
|
1296
|
+
def send_to_client(self, client_id, opcode, payload=b''):
|
|
1297
|
+
with self._lock:
|
|
1298
|
+
info = self._clients.get(client_id)
|
|
1299
|
+
if not info:
|
|
1300
|
+
print(f"[WS] send_to_client: cliente {client_id} no encontrado")
|
|
1301
|
+
return False, "no such client"
|
|
1302
|
+
sock = info['sock']
|
|
1303
|
+
try:
|
|
1304
|
+
sock.sendall(make_ws_frame_bytes(opcode, payload))
|
|
1305
|
+
print(f"[WS] send_to_client: cid={client_id} opcode={opcode} len={len(payload)}")
|
|
1306
|
+
return True, "sent"
|
|
1307
|
+
except Exception as e:
|
|
1308
|
+
print(f"[WS] Error en send_to_client {client_id}: {e}")
|
|
1309
|
+
return False, str(e)
|
|
1310
|
+
|
|
1311
|
+
def broadcast(self, opcode, payload=b''):
|
|
1312
|
+
failed = []
|
|
1313
|
+
with self._lock:
|
|
1314
|
+
items = list(self._clients.items())
|
|
1315
|
+
print(f"[WS] broadcast: opcode={opcode} len={len(payload)} a {len(items)} clientes")
|
|
1316
|
+
for cid, info in items:
|
|
1317
|
+
try:
|
|
1318
|
+
info['sock'].sendall(make_ws_frame_bytes(opcode, payload))
|
|
1319
|
+
print(f"[WS] broadcast -> cid={cid} OK")
|
|
1320
|
+
except Exception as e:
|
|
1321
|
+
print(f"[WS] broadcast -> cid={cid} ERROR: {e}")
|
|
1322
|
+
failed.append((cid, str(e)))
|
|
1323
|
+
return failed
|
|
1324
|
+
|
|
1325
|
+
# ============================================================
|
|
1326
|
+
# ConnectionThread (maneja una conexión TCP)
|
|
1327
|
+
# ============================================================
|
|
1328
|
+
|
|
1329
|
+
class ConnectionThread(threading.Thread):
|
|
1330
|
+
def __init__(self, conn, addr, registry, db):
|
|
1331
|
+
super().__init__(daemon=True)
|
|
1332
|
+
self.conn = conn
|
|
1333
|
+
self.addr = addr
|
|
1334
|
+
self.registry = registry
|
|
1335
|
+
self.db = db
|
|
1336
|
+
|
|
1337
|
+
def run(self):
|
|
1338
|
+
print(f"[HTTP] Nueva conexión desde {self.addr}")
|
|
1339
|
+
try:
|
|
1340
|
+
req = parse_http_request(self.conn)
|
|
1341
|
+
if not req:
|
|
1342
|
+
print(f"[HTTP] Petición vacía/incorrecta desde {self.addr}, cerrando.")
|
|
1343
|
+
self.conn.close()
|
|
1344
|
+
return
|
|
1345
|
+
|
|
1346
|
+
raw_path = req['path']
|
|
1347
|
+
method = req['method'].upper()
|
|
1348
|
+
headers = req['headers']
|
|
1349
|
+
print(f"[HTTP] {self.addr} {method} {raw_path}")
|
|
1350
|
+
path, _, query = raw_path.partition('?')
|
|
1351
|
+
req['path'] = path
|
|
1352
|
+
req['query'] = query
|
|
1353
|
+
|
|
1354
|
+
if path == '/ws/' and headers.get('upgrade','').lower() == 'websocket':
|
|
1355
|
+
client_id = str(uuid.uuid4())
|
|
1356
|
+
print(f"[HTTP->WS] Upgrade solicitado por {self.addr}, client_id={client_id}")
|
|
1357
|
+
self.handle_ws_connection(req, client_id)
|
|
1358
|
+
return
|
|
1359
|
+
|
|
1360
|
+
if path.startswith('/api/'):
|
|
1361
|
+
self.handle_api(req)
|
|
1362
|
+
self.conn.close()
|
|
1363
|
+
return
|
|
1364
|
+
|
|
1365
|
+
if path == '/' and method == 'GET':
|
|
1366
|
+
print(f"[HTTP] Sirviendo INDEX_HTML a {self.addr}")
|
|
1367
|
+
send_http_response(self.conn, 200, 'OK',
|
|
1368
|
+
{'Content-Type':'text/html; charset=utf-8'},
|
|
1369
|
+
INDEX_HTML)
|
|
1370
|
+
self.conn.close()
|
|
1371
|
+
return
|
|
1372
|
+
|
|
1373
|
+
print(f"[HTTP] 404 Not Found {path} desde {self.addr}")
|
|
1374
|
+
send_http_response(self.conn, 404, 'Not Found',
|
|
1375
|
+
{'Content-Type':'text/plain; charset=utf-8'},
|
|
1376
|
+
b'Not Found')
|
|
1377
|
+
self.conn.close()
|
|
1378
|
+
except Exception as e:
|
|
1379
|
+
print(f"[HTTP] Excepción manejando conexión desde {self.addr}: {e}")
|
|
1380
|
+
try:
|
|
1381
|
+
self.conn.close()
|
|
1382
|
+
except Exception:
|
|
1383
|
+
pass
|
|
1384
|
+
|
|
1385
|
+
# ----------------------------
|
|
1386
|
+
# API handler (por conexión, usa ClientRegistry + ORM)
|
|
1387
|
+
# ----------------------------
|
|
1388
|
+
def handle_api(self, req):
|
|
1389
|
+
method = req['method']
|
|
1390
|
+
path = req['path']
|
|
1391
|
+
headers = req['headers']
|
|
1392
|
+
body = req['remainder']
|
|
1393
|
+
query = req.get('query','') or ''
|
|
1394
|
+
|
|
1395
|
+
print(f"[API] {method} {path}?{query}")
|
|
1396
|
+
|
|
1397
|
+
if 'content-length' in headers:
|
|
1398
|
+
try:
|
|
1399
|
+
cl = int(headers['content-length'])
|
|
1400
|
+
except Exception:
|
|
1401
|
+
cl = 0
|
|
1402
|
+
if cl < 0:
|
|
1403
|
+
cl = 0
|
|
1404
|
+
if len(body) < cl:
|
|
1405
|
+
need = cl - len(body)
|
|
1406
|
+
if need > 0:
|
|
1407
|
+
body += recv_exact(self.conn, need)
|
|
1408
|
+
|
|
1409
|
+
if path == '/api/hello':
|
|
1410
|
+
if method.upper() == 'GET':
|
|
1411
|
+
print("[API] /api/hello GET")
|
|
1412
|
+
payload = '{"message":"Hello from server"}'.encode('utf-8')
|
|
1413
|
+
send_http_response(self.conn, 200, 'OK',
|
|
1414
|
+
{'Content-Type':'application/json; charset=utf-8'},
|
|
1415
|
+
payload)
|
|
1416
|
+
return True
|
|
1417
|
+
else:
|
|
1418
|
+
print("[API] /api/hello método no permitido")
|
|
1419
|
+
send_http_response(self.conn, 405, 'Method Not Allowed', {'Allow':'GET'}, b'')
|
|
1420
|
+
return True
|
|
1421
|
+
|
|
1422
|
+
if path == '/api/echo':
|
|
1423
|
+
if method.upper() == 'POST':
|
|
1424
|
+
try:
|
|
1425
|
+
body_text = body.decode('utf-8', errors='ignore')
|
|
1426
|
+
except Exception:
|
|
1427
|
+
body_text = ''
|
|
1428
|
+
print(f"[API] /api/echo cuerpo={body_text[:200]!r}")
|
|
1429
|
+
esc = body_text.replace('\\','\\\\').replace('"','\\"')
|
|
1430
|
+
payload = ('{"received":"%s"}' % esc).encode('utf-8')
|
|
1431
|
+
send_http_response(self.conn, 200, 'OK',
|
|
1432
|
+
{'Content-Type':'application/json; charset=utf-8'},
|
|
1433
|
+
payload)
|
|
1434
|
+
return True
|
|
1435
|
+
else:
|
|
1436
|
+
send_http_response(self.conn, 405, 'Method Not Allowed', {'Allow':'POST'}, b'')
|
|
1437
|
+
return True
|
|
1438
|
+
|
|
1439
|
+
if path == '/api/ws/clients':
|
|
1440
|
+
print("[API] /api/ws/clients listado")
|
|
1441
|
+
info = self.registry.list_clients_info()
|
|
1442
|
+
payload = json.dumps(info, default=str).encode('utf-8')
|
|
1443
|
+
send_http_response(self.conn, 200, 'OK',
|
|
1444
|
+
{'Content-Type':'application/json; charset=utf-8'},
|
|
1445
|
+
payload)
|
|
1446
|
+
return True
|
|
1447
|
+
|
|
1448
|
+
if path == '/api/ws/broadcast':
|
|
1449
|
+
if method.upper() != 'POST':
|
|
1450
|
+
send_http_response(self.conn, 405, 'Method Not Allowed', {'Allow':'POST'}, b'')
|
|
1451
|
+
return True
|
|
1452
|
+
try:
|
|
1453
|
+
jd = json.loads(body.decode('utf-8') or '{}')
|
|
1454
|
+
except Exception:
|
|
1455
|
+
jd = {}
|
|
1456
|
+
t = jd.get('type','text')
|
|
1457
|
+
message = jd.get('message','')
|
|
1458
|
+
print(f"[API] /api/ws/broadcast type={t} message={message[:100]!r}")
|
|
1459
|
+
if t == 'text':
|
|
1460
|
+
payload = message.encode('utf-8')
|
|
1461
|
+
failed = self.registry.broadcast(1, payload)
|
|
1462
|
+
else:
|
|
1463
|
+
b = jd.get('binary')
|
|
1464
|
+
if isinstance(b, list):
|
|
1465
|
+
payload = bytes(b)
|
|
1466
|
+
else:
|
|
1467
|
+
payload = (jd.get('message','')).encode('utf-8')
|
|
1468
|
+
failed = self.registry.broadcast(2, payload)
|
|
1469
|
+
if failed:
|
|
1470
|
+
print(f"[API] broadcast parcial, fallos={failed}")
|
|
1471
|
+
payload = json.dumps({'status':'partial','failed': failed}).encode('utf-8')
|
|
1472
|
+
send_http_response(self.conn, 500, 'Partial',
|
|
1473
|
+
{'Content-Type':'application/json; charset=utf-8'},
|
|
1474
|
+
payload)
|
|
1475
|
+
else:
|
|
1476
|
+
send_http_response(self.conn, 200, 'OK',
|
|
1477
|
+
{'Content-Type':'application/json; charset=utf-8'},
|
|
1478
|
+
json.dumps({'status':'ok'}).encode('utf-8'))
|
|
1479
|
+
return True
|
|
1480
|
+
|
|
1481
|
+
if path == '/api/ws/ping':
|
|
1482
|
+
if method.upper() != 'POST':
|
|
1483
|
+
send_http_response(self.conn, 405, 'Method Not Allowed', {'Allow':'POST'}, b'')
|
|
1484
|
+
return True
|
|
1485
|
+
try:
|
|
1486
|
+
jd = json.loads(body.decode('utf-8') or '{}')
|
|
1487
|
+
except Exception:
|
|
1488
|
+
jd = {}
|
|
1489
|
+
payload = jd.get('payload','').encode('utf-8')
|
|
1490
|
+
print(f"[API] /api/ws/ping payload={payload[:80]!r}")
|
|
1491
|
+
failed = self.registry.broadcast(9, payload)
|
|
1492
|
+
if failed:
|
|
1493
|
+
payload = json.dumps({'status':'partial','failed': failed}).encode('utf-8')
|
|
1494
|
+
send_http_response(self.conn, 500, 'Partial',
|
|
1495
|
+
{'Content-Type':'application/json; charset=utf-8'},
|
|
1496
|
+
payload)
|
|
1497
|
+
else:
|
|
1498
|
+
payload = json.dumps({'status':'ok'}).encode('utf-8')
|
|
1499
|
+
send_http_response(self.conn, 200, 'OK',
|
|
1500
|
+
{'Content-Type':'application/json; charset=utf-8'},
|
|
1501
|
+
payload)
|
|
1502
|
+
return True
|
|
1503
|
+
|
|
1504
|
+
if path == '/api/ws/close':
|
|
1505
|
+
if method.upper() != 'POST':
|
|
1506
|
+
send_http_response(self.conn, 405, 'Method Not Allowed', {'Allow':'POST'}, b'')
|
|
1507
|
+
return True
|
|
1508
|
+
try:
|
|
1509
|
+
jd = json.loads(body.decode('utf-8') or '{}')
|
|
1510
|
+
except Exception:
|
|
1511
|
+
jd = {}
|
|
1512
|
+
client_id = jd.get('client_id')
|
|
1513
|
+
code = int(jd.get('code', 1000))
|
|
1514
|
+
reason = jd.get('reason', '')
|
|
1515
|
+
if code < 0:
|
|
1516
|
+
code = 1000
|
|
1517
|
+
payload = code.to_bytes(2, 'big') + (reason.encode('utf-8') if reason else b'')
|
|
1518
|
+
print(f"[API] /api/ws/close client_id={client_id} code={code} reason={reason!r}")
|
|
1519
|
+
if client_id:
|
|
1520
|
+
ok, msg = self.registry.send_to_client(client_id, 8, payload)
|
|
1521
|
+
if ok:
|
|
1522
|
+
send_http_response(self.conn, 200, 'OK',
|
|
1523
|
+
{'Content-Type':'application/json; charset=utf-8'},
|
|
1524
|
+
b'{"status":"ok"}')
|
|
1525
|
+
else:
|
|
1526
|
+
send_http_response(self.conn, 500, 'Error',
|
|
1527
|
+
{'Content-Type':'application/json; charset=utf-8'},
|
|
1528
|
+
json.dumps({'status':'error','msg':msg}).encode('utf-8'))
|
|
1529
|
+
else:
|
|
1530
|
+
failed = self.registry.broadcast(8, payload)
|
|
1531
|
+
if failed:
|
|
1532
|
+
send_http_response(self.conn, 500, 'Partial',
|
|
1533
|
+
{'Content-Type':'application/json; charset=utf-8'},
|
|
1534
|
+
json.dumps({'status':'partial','failed':failed}).encode('utf-8'))
|
|
1535
|
+
else:
|
|
1536
|
+
send_http_response(self.conn, 200, 'OK',
|
|
1537
|
+
{'Content-Type':'application/json; charset=utf-8'},
|
|
1538
|
+
b'{"status":"ok"}')
|
|
1539
|
+
return True
|
|
1540
|
+
|
|
1541
|
+
# ----- API ORM: ChatMessage -----
|
|
1542
|
+
if path == '/api/chat/messages' and method.upper() == 'GET':
|
|
1543
|
+
params = parse_query_string(query)
|
|
1544
|
+
try:
|
|
1545
|
+
limit = int(params.get('limit', '50'))
|
|
1546
|
+
except Exception:
|
|
1547
|
+
limit = 50
|
|
1548
|
+
if limit <= 0:
|
|
1549
|
+
limit = 50
|
|
1550
|
+
if limit > 500:
|
|
1551
|
+
limit = 500
|
|
1552
|
+
msgs = ChatMessage.objects(self.db).order_by("id DESC").limit(limit).all()
|
|
1553
|
+
data = [m.to_dict() for m in msgs]
|
|
1554
|
+
payload = json.dumps(data).encode('utf-8')
|
|
1555
|
+
send_http_response(self.conn, 200, 'OK',
|
|
1556
|
+
{'Content-Type':'application/json; charset=utf-8'},
|
|
1557
|
+
payload)
|
|
1558
|
+
return True
|
|
1559
|
+
|
|
1560
|
+
if path == '/api/chat/clear' and method.upper() == 'POST':
|
|
1561
|
+
print("[API] /api/chat/clear -> borrando mensajes")
|
|
1562
|
+
with self.db.transaction():
|
|
1563
|
+
borrados = ChatMessage.objects(self.db).delete()
|
|
1564
|
+
payload = json.dumps({'status': 'ok', 'deleted': borrados}).encode('utf-8')
|
|
1565
|
+
send_http_response(self.conn, 200, 'OK',
|
|
1566
|
+
{'Content-Type':'application/json; charset=utf-8'},
|
|
1567
|
+
payload)
|
|
1568
|
+
return True
|
|
1569
|
+
|
|
1570
|
+
if path == '/' and method.upper() == 'GET':
|
|
1571
|
+
print("[API] GET / (INDEX_HTML) desde handle_api")
|
|
1572
|
+
send_http_response(self.conn, 200, 'OK',
|
|
1573
|
+
{'Content-Type':'text/html; charset=utf-8'},
|
|
1574
|
+
INDEX_HTML)
|
|
1575
|
+
return True
|
|
1576
|
+
|
|
1577
|
+
print(f"[API] 404 Not Found: {path}")
|
|
1578
|
+
send_http_response(self.conn, 404, 'Not Found',
|
|
1579
|
+
{'Content-Type':'text/plain; charset=utf-8'},
|
|
1580
|
+
b'Not Found')
|
|
1581
|
+
return True
|
|
1582
|
+
|
|
1583
|
+
# ----------------------------
|
|
1584
|
+
# WebSocket per-connection handler
|
|
1585
|
+
# ----------------------------
|
|
1586
|
+
def handle_ws_connection(self, initial_req, client_id):
|
|
1587
|
+
headers = initial_req['headers']
|
|
1588
|
+
|
|
1589
|
+
subprotocol = ''
|
|
1590
|
+
saw_proto = headers.get('sec-websocket-protocol','')
|
|
1591
|
+
if saw_proto:
|
|
1592
|
+
offered = [p.strip() for p in saw_proto.split(',') if p.strip()]
|
|
1593
|
+
if offered:
|
|
1594
|
+
subprotocol = offered[0]
|
|
1595
|
+
|
|
1596
|
+
key = headers.get('sec-websocket-key', '')
|
|
1597
|
+
if not key:
|
|
1598
|
+
print(f"[WS] Handshake inválido desde {self.addr}, falta Sec-WebSocket-Key")
|
|
1599
|
+
send_http_response(self.conn, 400, 'Bad Request',
|
|
1600
|
+
{'Content-Type':'text/plain; charset=utf-8'},
|
|
1601
|
+
b'Missing Sec-WebSocket-Key')
|
|
1602
|
+
try:
|
|
1603
|
+
self.conn.close()
|
|
1604
|
+
except Exception:
|
|
1605
|
+
pass
|
|
1606
|
+
return
|
|
1607
|
+
|
|
1608
|
+
print(f"[WS] Handshake desde {self.addr} client_id={client_id} subprotocols_ofrecidos={saw_proto!r}")
|
|
1609
|
+
|
|
1610
|
+
accept_src = (key + MAGIC_WS).encode('utf-8')
|
|
1611
|
+
digest = sha1(accept_src)
|
|
1612
|
+
accept = base64_encode(digest)
|
|
1613
|
+
resp_headers = {
|
|
1614
|
+
'Upgrade': 'websocket',
|
|
1615
|
+
'Connection': 'Upgrade',
|
|
1616
|
+
'Sec-WebSocket-Accept': accept
|
|
1617
|
+
}
|
|
1618
|
+
if subprotocol:
|
|
1619
|
+
resp_headers['Sec-WebSocket-Protocol'] = subprotocol
|
|
1620
|
+
|
|
1621
|
+
send_http_response(self.conn, 101, 'Switching Protocols', resp_headers, b'')
|
|
1622
|
+
|
|
1623
|
+
self.registry.register_client(client_id, self.conn, self.addr, threading.current_thread(), subprotocol)
|
|
1624
|
+
|
|
1625
|
+
try:
|
|
1626
|
+
welcome = json.dumps({
|
|
1627
|
+
"type": "welcome",
|
|
1628
|
+
"client_id": client_id,
|
|
1629
|
+
"subprotocol": subprotocol
|
|
1630
|
+
}).encode('utf-8')
|
|
1631
|
+
self.conn.sendall(make_ws_frame_bytes(1, welcome))
|
|
1632
|
+
print(f"[WS] Enviado mensaje welcome a {client_id}")
|
|
1633
|
+
except Exception as e:
|
|
1634
|
+
print(f"[WS] Error enviando welcome a {client_id}: {e}")
|
|
1635
|
+
|
|
1636
|
+
fragmented_msg_opcode = None
|
|
1637
|
+
fragmented_parts = []
|
|
1638
|
+
try:
|
|
1639
|
+
while True:
|
|
1640
|
+
fin, opcode, payload, masked, mask = read_ws_frame_raw(self.conn)
|
|
1641
|
+
plen = len(payload)
|
|
1642
|
+
print(f"[WS RECV] cid={client_id} opcode={opcode} fin={fin} masked={masked} len={plen}")
|
|
1643
|
+
|
|
1644
|
+
if opcode == 0x8:
|
|
1645
|
+
code, reason = parse_close_payload(payload)
|
|
1646
|
+
print(f"[WS CLOSE] cid={client_id} code={code} reason={reason!r}")
|
|
1647
|
+
try:
|
|
1648
|
+
close_payload = payload if payload else b''
|
|
1649
|
+
self.conn.sendall(make_ws_frame_bytes(0x8, close_payload))
|
|
1650
|
+
except Exception as e:
|
|
1651
|
+
print(f"[WS] Error respondiendo CLOSE a {client_id}: {e}")
|
|
1652
|
+
break
|
|
1653
|
+
elif opcode == 0x9:
|
|
1654
|
+
print(f"[WS PING] cid={client_id} len={plen}")
|
|
1655
|
+
try:
|
|
1656
|
+
self.conn.sendall(make_ws_frame_bytes(0xA, payload))
|
|
1657
|
+
print(f"[WS PONG] enviado a cid={client_id}")
|
|
1658
|
+
except Exception as e:
|
|
1659
|
+
print(f"[WS] Error enviando PONG a {client_id}: {e}")
|
|
1660
|
+
continue
|
|
1661
|
+
elif opcode == 0xA:
|
|
1662
|
+
print(f"[WS PONG RECV] cid={client_id} len={plen}")
|
|
1663
|
+
continue
|
|
1664
|
+
|
|
1665
|
+
if opcode == 0x0:
|
|
1666
|
+
if fragmented_msg_opcode is None:
|
|
1667
|
+
print(f"[WS] CONTINUATION sin inicio previo en cid={client_id}, descartando fragmentos")
|
|
1668
|
+
fragmented_parts = []
|
|
1669
|
+
fragmented_msg_opcode = None
|
|
1670
|
+
continue
|
|
1671
|
+
fragmented_parts.append(payload)
|
|
1672
|
+
if fin:
|
|
1673
|
+
full = b''.join(fragmented_parts)
|
|
1674
|
+
if fragmented_msg_opcode == 1:
|
|
1675
|
+
try:
|
|
1676
|
+
text = full.decode('utf-8', errors='ignore')
|
|
1677
|
+
except Exception:
|
|
1678
|
+
text = ''
|
|
1679
|
+
print(f"[WS TEXT FRAG COMPLETO] cid={client_id} text={text[:120]!r}")
|
|
1680
|
+
self._handle_text_message(client_id, text, fragmented=True)
|
|
1681
|
+
elif fragmented_msg_opcode == 2:
|
|
1682
|
+
print(f"[WS BIN FRAG COMPLETO] cid={client_id} len={len(full)}")
|
|
1683
|
+
prefix = b"BIN ECHO:"
|
|
1684
|
+
self.conn.sendall(make_ws_frame_bytes(2, prefix + full))
|
|
1685
|
+
fragmented_parts = []
|
|
1686
|
+
fragmented_msg_opcode = None
|
|
1687
|
+
else:
|
|
1688
|
+
print(f"[WS] CONTINUATION (parte intermedia) cid={client_id} len_parte={plen}")
|
|
1689
|
+
elif opcode == 0x1 or opcode == 0x2:
|
|
1690
|
+
if fin:
|
|
1691
|
+
if opcode == 0x1:
|
|
1692
|
+
text = payload.decode('utf-8', errors='ignore')
|
|
1693
|
+
print(f"[WS TEXT] cid={client_id} text={text[:200]!r}")
|
|
1694
|
+
self._handle_text_message(client_id, text, fragmented=False)
|
|
1695
|
+
else:
|
|
1696
|
+
print(f"[WS BIN] cid={client_id} len={plen}")
|
|
1697
|
+
prefix = b"BIN ECHO:"
|
|
1698
|
+
self.conn.sendall(make_ws_frame_bytes(2, prefix + payload))
|
|
1699
|
+
else:
|
|
1700
|
+
fragmented_msg_opcode = opcode
|
|
1701
|
+
fragmented_parts = [payload]
|
|
1702
|
+
print(f"[WS] Inicio de mensaje fragmentado cid={client_id} opcode={opcode} len={plen}")
|
|
1703
|
+
else:
|
|
1704
|
+
print(f"[WS] Opcode reservado/no soportado {opcode} en cid={client_id}, ignorando.")
|
|
1705
|
+
continue
|
|
1706
|
+
except ConnectionError:
|
|
1707
|
+
print(f"[WS] ConnectionError en cid={client_id}")
|
|
1708
|
+
except Exception as e:
|
|
1709
|
+
print(f"[WS] Excepción en loop de cid={client_id}: {e}")
|
|
1710
|
+
finally:
|
|
1711
|
+
self.registry.unregister_client(client_id)
|
|
1712
|
+
try:
|
|
1713
|
+
self.conn.close()
|
|
1714
|
+
except Exception:
|
|
1715
|
+
pass
|
|
1716
|
+
|
|
1717
|
+
def _handle_text_message(self, client_id, text, fragmented=False):
|
|
1718
|
+
alias, msg = parse_chat_line(text)
|
|
1719
|
+
ts = int(time.time())
|
|
1720
|
+
if msg.strip():
|
|
1721
|
+
try:
|
|
1722
|
+
ChatMessage.objects(self.db).create(
|
|
1723
|
+
client_id=client_id,
|
|
1724
|
+
alias=alias or 'anon',
|
|
1725
|
+
message=msg,
|
|
1726
|
+
created_at=ts
|
|
1727
|
+
)
|
|
1728
|
+
print(f"[CHAT/ORM] Guardado mensaje chat cid={client_id} alias={alias or 'anon'} len={len(msg)}")
|
|
1729
|
+
except Exception as e:
|
|
1730
|
+
print(f"[CHAT/ORM] Error guardando mensaje: {e}")
|
|
1731
|
+
if fragmented:
|
|
1732
|
+
reply = "Server echo (frag): " + text
|
|
1733
|
+
else:
|
|
1734
|
+
reply = "Server echo: " + text
|
|
1735
|
+
try:
|
|
1736
|
+
self.conn.sendall(make_ws_frame_bytes(1, reply.encode('utf-8')))
|
|
1737
|
+
except Exception as e:
|
|
1738
|
+
print(f"[WS] Error enviando echo a cid={client_id}: {e}")
|
|
1739
|
+
|
|
1740
|
+
# ============================================================
|
|
1741
|
+
# Servidor principal
|
|
1742
|
+
# ============================================================
|
|
1743
|
+
|
|
1744
|
+
class WebSocketHTTPServer:
|
|
1745
|
+
def __init__(self, host, port):
|
|
1746
|
+
self.host = host
|
|
1747
|
+
self.port = port
|
|
1748
|
+
self.registry = ClientRegistry()
|
|
1749
|
+
self._sock = None
|
|
1750
|
+
|
|
1751
|
+
# BD en memoria compartida: file::memory:?cache=shared
|
|
1752
|
+
self.db = Database(
|
|
1753
|
+
path=":memory:",
|
|
1754
|
+
shared_cache=True,
|
|
1755
|
+
timeout=10.0,
|
|
1756
|
+
pragmas={
|
|
1757
|
+
"journal_mode": "WAL",
|
|
1758
|
+
"synchronous": "NORMAL",
|
|
1759
|
+
"foreign_keys": 1,
|
|
1760
|
+
},
|
|
1761
|
+
)
|
|
1762
|
+
# Crear tabla de ejemplo para el chat
|
|
1763
|
+
ChatMessage.create_table(self.db)
|
|
1764
|
+
|
|
1765
|
+
def serve_forever(self):
|
|
1766
|
+
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
1767
|
+
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
1768
|
+
s.bind((self.host, self.port))
|
|
1769
|
+
s.listen(128)
|
|
1770
|
+
self._sock = s
|
|
1771
|
+
print("Servidor escuchando en http://%s:%d/ ..." % (self.host, self.port))
|
|
1772
|
+
try:
|
|
1773
|
+
while True:
|
|
1774
|
+
conn, addr = s.accept()
|
|
1775
|
+
print(f"[ACCEPT] Conexión aceptada desde {addr}")
|
|
1776
|
+
t = ConnectionThread(conn, addr, self.registry, self.db)
|
|
1777
|
+
t.start()
|
|
1778
|
+
except KeyboardInterrupt:
|
|
1779
|
+
print("\n[shutdown] interrupted by user (Ctrl+C). stopping server...")
|
|
1780
|
+
finally:
|
|
1781
|
+
s.close()
|
|
1782
|
+
print("[shutdown] server socket closed.")
|
|
1783
|
+
|
|
1784
|
+
def run_server(host, port):
|
|
1785
|
+
srv = WebSocketHTTPServer(host, port)
|
|
1786
|
+
srv.serve_forever()
|
|
1787
|
+
|
|
1788
|
+
if __name__ == '__main__':
|
|
1789
|
+
run_server(HOST, PORT)
|
|
1790
|
+
|
|
1791
|
+
# ============================================================
|
|
1792
|
+
# EJEMPLOS DE USO (HTTP / API / WS)
|
|
1793
|
+
# ============================================================
|
|
1794
|
+
# HTTP:
|
|
1795
|
+
# - Abrir en navegador: http://0.0.0.0:8765/
|
|
1796
|
+
#
|
|
1797
|
+
# API (ejemplos con curl):
|
|
1798
|
+
# - Listar clientes WS:
|
|
1799
|
+
# curl http://0.0.0.0:8765/api/ws/clients
|
|
1800
|
+
#
|
|
1801
|
+
# - Broadcast de texto:
|
|
1802
|
+
# curl -X POST http://0.0.0.0:8765/api/ws/broadcast \
|
|
1803
|
+
# -H "Content-Type: application/json" \
|
|
1804
|
+
# -d '{"type":"text","message":"Hola a todos desde curl"}'
|
|
1805
|
+
#
|
|
1806
|
+
# - Ping a todos:
|
|
1807
|
+
# curl -X POST http://0.0.0.0:8765/api/ws/ping \
|
|
1808
|
+
# -H "Content-Type: application/json" \
|
|
1809
|
+
# -d '{"payload":"ping-desde-api"}'
|
|
1810
|
+
#
|
|
1811
|
+
# - Cerrar todos los clientes:
|
|
1812
|
+
# curl -X POST http://0.0.0.0:8765/api/ws/close \
|
|
1813
|
+
# -H "Content-Type: application/json" \
|
|
1814
|
+
# -d '{"code":1000,"reason":"bye"}'
|
|
1815
|
+
#
|
|
1816
|
+
# - Listar mensajes de chat guardados en SQLite:
|
|
1817
|
+
# curl "http://0.0.0.0:8765/api/chat/messages?limit=20"
|
|
1818
|
+
#
|
|
1819
|
+
# - Borrar todos los mensajes de chat:
|
|
1820
|
+
# curl -X POST http://0.0.0.0:8765/api/chat/clear
|
|
1821
|
+
#
|
|
1822
|
+
# WebSocket (WS):
|
|
1823
|
+
# - Desde el HTML:
|
|
1824
|
+
# * Conectar con "Conectar WS".
|
|
1825
|
+
# * En la parte de chat:
|
|
1826
|
+
# - Pon un alias.
|
|
1827
|
+
# - Escribe un mensaje y "Enviar".
|
|
1828
|
+
# Se manda algo tipo: "[alias] mensaje".
|
|
1829
|
+
# El servidor:
|
|
1830
|
+
# - lo guarda en la tabla "chat_messages" (ORM SQLite)
|
|
1831
|
+
# - responde con "Server echo: [alias] mensaje" al mismo WS.
|
|
1832
|
+
#
|
|
1833
|
+
# - Desde herramientas WS (wscat, websocat, etc.):
|
|
1834
|
+
# wscat -c ws://0.0.0.0:8765/ws/
|
|
1835
|
+
# > [pepe] hola desde wscat
|
|
1836
|
+
# < Server echo: [pepe] hola desde wscat
|
|
1837
|
+
# Y el mensaje se almacena en "chat_messages".
|