yacscript 0.3.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.
app/app.py ADDED
@@ -0,0 +1,698 @@
1
+ import atexit
2
+ import random
3
+ import tomllib
4
+ import uuid
5
+ from base64 import b64encode
6
+ from datetime import UTC, datetime
7
+ from functools import wraps
8
+ from os import path, mkdir
9
+ from string import ascii_lowercase, ascii_uppercase
10
+ from time import time
11
+ from collections import OrderedDict
12
+
13
+ import bbcode
14
+ from app.db import get_db
15
+ from app.definitions import CONFIG_DEFAULT
16
+ from captcha.image import ImageCaptcha
17
+ from flask import (Flask, Response, redirect, render_template,
18
+ request, send_file, jsonify)
19
+ from flask_socketio import (SocketIO, emit, join_room,
20
+ leave_room, disconnect)
21
+ from webargs import fields, validate
22
+ from webargs.flaskparser import use_args
23
+ from werkzeug.middleware.proxy_fix import ProxyFix
24
+ from werkzeug.utils import secure_filename
25
+
26
+
27
+ # --- GLOBAL DEFINITIONS ---
28
+
29
+ app = Flask(__name__)
30
+ socketio = SocketIO(app, cors_allowed_origins='*')
31
+
32
+ # Caches
33
+ candidates: OrderedDict = OrderedDict()
34
+ online = dict()
35
+
36
+
37
+ # --- HELPER FUNCTIONS ---
38
+
39
+ def initialization(config=None):
40
+ app.config.update(CONFIG_DEFAULT)
41
+ if config is not None:
42
+ if path.isfile(path.abspath(config)):
43
+ with open(path.abspath(path.abspath(config)), 'rb') as file:
44
+ try:
45
+ conf = tomllib.load(file)
46
+ except tomllib.TOMLDecodeError as e:
47
+ return e
48
+ # read built-in values
49
+ top = conf.get('flask', None)
50
+ if top:
51
+ app.config.update(conf['flask'])
52
+ conf.pop('flask')
53
+ app.config.update(conf)
54
+ if app.config['app']['proxy_fix']:
55
+ app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_host=1)
56
+ if not path.isdir(app.config['res']['path']):
57
+ mkdir(app.config['res']['path'])
58
+
59
+ # Making global variables
60
+ global logger
61
+ global conn
62
+ global SIZE_MAX_BYTE
63
+ global challenge_set
64
+ global image_captcha
65
+ app.logger.setLevel(app.config['app']['log_level'])
66
+ logger = app.logger
67
+ conn = get_db(app.config['db']['path'], logger)
68
+ SIZE_MAX_BYTE = app.config['res']['size_max'] * 1000000
69
+ options = app.config['captcha']['options']
70
+ challenge_set = ['', ascii_lowercase][options['lowercase']] + \
71
+ ['', ascii_uppercase][options['uppercase']] + \
72
+ ['', '0123456789'][options['numbers']]
73
+ image_captcha = ImageCaptcha()
74
+ return 'ok'
75
+
76
+
77
+ def get_channel_member(channel_id: int):
78
+ resp = []
79
+ for k, v in online.items():
80
+ cid = v['channel']
81
+ if cid == channel_id:
82
+ resp.append(k)
83
+ return resp
84
+
85
+
86
+ def push_candidate(identifier, challenge, time):
87
+ if len(candidates) >= app.config['captcha']['max_cache']:
88
+ candidates.popitem(last=False)
89
+ candidates[identifier] = (challenge, time)
90
+
91
+
92
+ def verify_candidate(identifier, challenge, time):
93
+ try:
94
+ candidate = candidates.pop(identifier)
95
+ except:
96
+ return False
97
+ if (time - candidate[1]) > app.config['captcha']['expire']:
98
+ return False
99
+ if challenge == candidate[0]:
100
+ return True
101
+ return False
102
+
103
+
104
+ def clean_user(name):
105
+ item = online.pop(name)
106
+ if item['channel'] != 0:
107
+ emit('leaving', {'target': name},
108
+ to=item['channel'], namespace=item['namespace'])
109
+ leave_room(
110
+ item['channel'],
111
+ item['sid'],
112
+ item['namespace']
113
+ )
114
+ disconnect(
115
+ item['sid'],
116
+ item['namespace']
117
+ )
118
+ logger.info(f'User {name} logged out.')
119
+
120
+
121
+ def time_milisecond():
122
+ return round(time() * 1000)
123
+
124
+
125
+ def garbage_collect():
126
+ for k, v in list(online.items()):
127
+ if (time_milisecond() - v['last_heartbeat']) > app.config['app']['timeout']:
128
+ clean_user(k)
129
+
130
+
131
+ @app.errorhandler(422)
132
+ @app.errorhandler(400)
133
+ def handle_error(err):
134
+ headers = err.data.get('headers', None)
135
+ messages = err.data.get('messages', ['Invalid request.'])
136
+ if headers:
137
+ return jsonify({'errors': messages}), err.code, headers
138
+ else:
139
+ return jsonify({'errors': messages}), err.code
140
+
141
+ # --- WRAPPERS ---
142
+
143
+
144
+ def login_required(f):
145
+ @wraps(f)
146
+ def decorated(*args, **kwargs):
147
+ auth = request.headers.get('Authorization', default=None)
148
+ if not auth:
149
+ return Response(status=401)
150
+ try:
151
+ _, nick, token = str(auth).split(' ')
152
+ except:
153
+ return Response(status=401)
154
+ if nick not in online.keys():
155
+ return Response(status=401)
156
+ if not online[nick]['token'] == token:
157
+ return Response(status=401)
158
+ return f(*args, **kwargs)
159
+ return decorated
160
+
161
+
162
+ def admin_login_required(f):
163
+ @wraps(f)
164
+ def decorated(*args, **kwargs):
165
+ auth = request.headers.get('Authorization', default=None)
166
+ if not auth:
167
+ return Response(status=401)
168
+ try:
169
+ _, nick, token = str(auth).split(' ')
170
+ except:
171
+ return Response(status=401)
172
+ if nick not in online.keys():
173
+ return Response(status=401)
174
+ if not online[nick]['token'] == token:
175
+ return Response(status=401)
176
+ if not online[nick]['is_admin']:
177
+ return Response(status=401)
178
+ return f(*args, **kwargs)
179
+ return decorated
180
+
181
+
182
+ # --- VIEW ROUTES ---
183
+ # ROUTES HERE ARE SPECIFIALLY FOR RENDERING VIEWS
184
+
185
+ @app.route('/')
186
+ def landing_page():
187
+ # get possible failure callback
188
+ failed = request.args.get('failed', default=None)
189
+ if failed:
190
+ failed = failed.replace('+', ' ')
191
+ # making captcha
192
+ identifier = str(uuid.uuid4())
193
+ challenge = ''.join(random.choices(
194
+ challenge_set, k=app.config['captcha']['length']))
195
+ push_candidate(identifier, challenge, time())
196
+ data = image_captcha.generate(challenge).read()
197
+ captcha_data = b64encode(data).decode()
198
+ return render_template(
199
+ 'index.jinja',
200
+ title=app.config['custom']['title'],
201
+ captcha=captcha_data,
202
+ identifier=identifier,
203
+ failed=failed
204
+ )
205
+
206
+
207
+ LOGIN_FORM = {
208
+ 'nick': fields.Str(required=True, validate=[validate.Length(min=3, max=16), validate.ContainsOnly(list(ascii_lowercase + ascii_uppercase + '0123456789_'))]),
209
+ 'phrase': fields.Str(),
210
+ 'captcha': fields.Str(required=True),
211
+ 'identifier': fields.Str(required=True)
212
+ }
213
+
214
+
215
+ @app.route('/auth', methods=['POST'])
216
+ @use_args(LOGIN_FORM, location='form')
217
+ def auth(form):
218
+ if form['phrase'] != app.config['app']['admin_phrase']:
219
+ if form['phrase'] != app.config['app']['user_phrase']:
220
+ return redirect('/?failed=Wrong+passphrase')
221
+
222
+ if verify_candidate(form['identifier'], form['captcha'], time()):
223
+ garbage_collect()
224
+ if form['nick'] in online.keys():
225
+ return redirect('/?failed=User+exists')
226
+ token = str(uuid.uuid4())
227
+ is_admin = (form['phrase'] == app.config['app']['admin_phrase'])
228
+ online[form['nick']] = dict(
229
+ token=token,
230
+ is_admin=is_admin,
231
+ last_heartbeat=time_milisecond(),
232
+ files_pending=dict(),
233
+ channel=0,
234
+ sid=None,
235
+ namespace=None,
236
+ is_uploading=False,
237
+ is_alive=False
238
+ )
239
+ logger.info(f'User {form['nick']} logged in.')
240
+ return redirect(f'/room?nickname={form['nick']}&token={token}')
241
+ return redirect('/?failed=CAPTCHA+failed')
242
+
243
+
244
+ @app.route('/room-preview', methods=['POST', 'GET'])
245
+ def room_preview():
246
+ if not app.config['DEBUG']:
247
+ return Response(status=404)
248
+ name = 'test_' + ''.join(random.choices(challenge_set, k=5))
249
+ online[name] = {
250
+ 'token': 'test',
251
+ 'is_admin': True,
252
+ 'last_heartbeat': time_milisecond(),
253
+ 'files_pending': dict(),
254
+ 'channel': 0,
255
+ 'sid': None,
256
+ 'namespace': None,
257
+ 'is_uploading': False,
258
+ 'is_alive': False,
259
+ }
260
+ return render_template(
261
+ 'room.jinja',
262
+ title=app.config['custom']['title'],
263
+ token='test',
264
+ nick=name,
265
+ emotes=app.config['custom']['emoticons'],
266
+ motd=app.config['custom']['motd'],
267
+ is_admin=True
268
+ )
269
+
270
+
271
+ ROOM_FORM = {
272
+ 'nickname': fields.Str(validate=lambda x: (x in online.keys()), required=True),
273
+ 'token': fields.Str(required=True),
274
+ }
275
+
276
+
277
+ @app.route('/room')
278
+ @use_args(ROOM_FORM, location='query')
279
+ def room_view(auth):
280
+ nick = auth['nickname']
281
+ token = auth['token']
282
+ try:
283
+ if online[nick]['token'] != token:
284
+ return redirect('/?failed=Invalid+authentication')
285
+ except:
286
+ return redirect('/?failed=Invalid+authentication')
287
+ if online[nick]['last_heartbeat']:
288
+ if (time_milisecond() - online[nick]['last_heartbeat']) > app.config['app']['timeout']:
289
+ clean_user(nick)
290
+ return redirect('/?failed=Authentication+expired')
291
+ return render_template(
292
+ 'room.jinja',
293
+ title=app.config['custom']['title'],
294
+ emotes=app.config['custom']['emoticons'],
295
+ nick=nick,
296
+ token=token,
297
+ motd=app.config['custom']['motd'],
298
+ is_admin=online[nick]['is_admin'],
299
+ timeout=app.config['app']['timeout'],
300
+ )
301
+
302
+ # --- SECRET API ---
303
+ # ROUTES HERE NEED TO BE CALLED BY LOGGED IN CLIENT SCRIPT
304
+
305
+
306
+ @app.route('/channels')
307
+ @login_required
308
+ def get_channels():
309
+ auth = request.headers.get('Authorization', default=None)
310
+ nick = str(auth).split(' ')[1]
311
+ res = conn.execute(
312
+ 'SELECT * FROM CHANNEL WHERE IS_DELETED=0 AND (ADMIN_ONLY=0 OR ?=1)', (online[nick]['is_admin'],))
313
+ channels = []
314
+ for row in res.fetchall():
315
+ channels.append({
316
+ 'id': row[0],
317
+ 'name': row[1],
318
+ 'is_admin': bool(row[3])
319
+ })
320
+ return channels
321
+
322
+
323
+ @app.route('/index_upload', methods=['POST'])
324
+ @login_required
325
+ def upload_index():
326
+ auth = request.headers.get('Authorization', default=None)
327
+ uploader = str(auth).split(' ')[1]
328
+ if online[uploader]['is_uploading']:
329
+ return Response(status=429)
330
+ # lock
331
+ online[uploader]['is_uploading'] = True
332
+
333
+ file = request.files.get("file", default=None)
334
+ if file is None:
335
+ online[uploader]['is_uploading'] = False
336
+ return Response(status=400)
337
+
338
+ name = file.filename
339
+ if name is None:
340
+ online[uploader]['is_uploading'] = False
341
+ return Response(status=400)
342
+
343
+ name = secure_filename(name)
344
+ mime = file.mimetype
345
+ binary = file.read()
346
+ if len(binary) > SIZE_MAX_BYTE:
347
+ online[uploader]['is_uploading'] = False
348
+ return Response(status=400)
349
+ id = str(uuid.uuid4())
350
+ online[uploader]['files_pending'].update({
351
+ id: (binary, name, mime)
352
+ })
353
+ online[uploader]['is_uploading'] = False
354
+ return {'uuid': id, 'file_name': name}
355
+
356
+
357
+ @app.route('/submit_upload')
358
+ @login_required
359
+ def upload_submit():
360
+ auth = request.headers.get('Authorization', default=None)
361
+ user = str(auth).split(' ')[1]
362
+
363
+ recall = request.args.get('recall', default=None)
364
+ submit = request.args.get('submit', default=None)
365
+
366
+ if recall:
367
+ if recall not in online[user]['files_pending'].keys():
368
+ return Response(status=400)
369
+ online[user]['files_pending'].pop(recall)
370
+ return Response(status=200)
371
+
372
+ if submit:
373
+ if submit not in online[user]['files_pending'].keys():
374
+ return Response(status=400)
375
+ binary, name, mime = online[user]['files_pending'].pop(submit)
376
+
377
+ # Write to disk
378
+ extension = path.splitext(name)[1]
379
+ file_path = path.abspath(
380
+ path.join(app.config['res']['path'], f'{submit}{extension}'))
381
+ with open(file_path, 'xb') as f:
382
+ f.write(binary)
383
+
384
+ # Write to DB
385
+ conn.execute(
386
+ 'INSERT INTO RESOURCE (UUID, FILE_NAME, MIME_TYPE) VALUES (?, ?, ?);',
387
+ (submit, name, mime)
388
+ )
389
+ return Response(status=200)
390
+ return Response(status=400)
391
+
392
+
393
+ @app.route('/messages/<int:channel_id>')
394
+ @login_required
395
+ def get_messages(channel_id):
396
+ if channel_id == 0:
397
+ return Response(status=404)
398
+ count = 30
399
+ if 'count' in request.args.keys():
400
+ count = int(request.args['count'])
401
+ offset = 0
402
+ if 'offset' in request.args.keys():
403
+ offset = int(request.args['offset'])
404
+
405
+ # check your fucking privilege
406
+ auth = request.headers.get('Authorization', default=None)
407
+ nick = str(auth).split(' ')[1]
408
+ cur = conn.execute(
409
+ 'SELECT ADMIN_ONLY FROM CHANNEL WHERE ID=?;', (channel_id,))
410
+ row = cur.fetchone()
411
+ if row is None:
412
+ return Response(status=403)
413
+ priv = row[0]
414
+ if priv == 1 and online[nick]['is_admin'] == 0:
415
+ return Response(status=403)
416
+
417
+ # get latest message first
418
+ res = conn.execute(
419
+ 'SELECT * FROM CHAT WHERE CHANNEL_ID=? AND IS_DELETED=0 ORDER BY ID DESC LIMIT ? OFFSET ?;', (channel_id, count, offset))
420
+ res = res.fetchall()
421
+ # Process BBCODE
422
+ resp = []
423
+ for row in res:
424
+ cur = conn.execute(
425
+ 'SELECT RESOURCE_ID FROM ATTACHMENT WHERE CHAT_ID=?;', (row[0],))
426
+ cur = cur.fetchall()
427
+ attachments = [x[0] for x in cur]
428
+ msg = {
429
+ 'id': row[0],
430
+ 'author': row[4],
431
+ 'datetime': row[2],
432
+ 'body': row[1],
433
+ 'attachments': attachments
434
+ }
435
+ resp.append(msg)
436
+ return resp
437
+
438
+
439
+ @app.route('/fellows')
440
+ @login_required
441
+ def get_fellows():
442
+ auth = request.headers.get('Authorization', default=None)
443
+ if auth == None:
444
+ # auth is guaranteed to be valid after decoration
445
+ # so uh this is merely a trick to disable static checker
446
+ return Response(status=400)
447
+ user = auth.split(' ')[1]
448
+ channel = online[user]['channel']
449
+ if channel == 0:
450
+ return Response(status=404)
451
+ return {'fellows': get_channel_member(channel)}
452
+
453
+ # --- PUBLIC APIS ---
454
+
455
+
456
+ @app.route('/resource_meta/<resource_id>')
457
+ def get_resource_meta(resource_id):
458
+ row = conn.execute(
459
+ 'SELECT FILE_NAME, MIME_TYPE FROM RESOURCE WHERE IS_EXPIRED=0 AND UUID=?;', (resource_id, ))
460
+ row = row.fetchone()
461
+ if not row:
462
+ return Response(status=404)
463
+ return {
464
+ 'filename': row[0],
465
+ 'mime': row[1]
466
+ }
467
+
468
+
469
+ @app.route('/resource/<resource_id>')
470
+ def get_resource(resource_id):
471
+ row = conn.execute(
472
+ 'SELECT FILE_NAME, MIME_TYPE FROM RESOURCE WHERE IS_EXPIRED=0 AND UUID=?;', (resource_id, ))
473
+ row = row.fetchone()
474
+ if not row:
475
+ return Response(status=404)
476
+ extension = path.splitext(row[0])[1]
477
+ file_path = path.abspath(path.join(
478
+ app.config['res']['path'],
479
+ f'{resource_id}.{extension}'
480
+ ))
481
+ if path.exists(file_path):
482
+ return send_file(file_path, mimetype=row[1], download_name=row[0])
483
+ file_path = path.abspath(path.join(
484
+ app.config['res']['path'],
485
+ f'{resource_id}{extension}'
486
+ ))
487
+ if path.exists(file_path):
488
+ return send_file(file_path, mimetype=row[1], download_name=row[0])
489
+ try:
490
+ conn.execute(
491
+ 'UPDATE RESOURCE SET IS_EXPIRED=1 WHERE UUID=?;',
492
+ (resource_id, )
493
+ )
494
+ except Exception as e:
495
+ logger.error(
496
+ f'Error deleting resource {resource_id} with exception {e}')
497
+ return Response(status=404)
498
+
499
+ # --- ADMIN APIS ---
500
+
501
+
502
+ @app.route('/channel', methods=['POST', 'PUT', 'DELETE'])
503
+ @admin_login_required
504
+ def channel_control():
505
+ if request.method == 'POST':
506
+ try:
507
+ name = request.get_json()['name']
508
+ except:
509
+ return Response(status=415)
510
+ conn.execute(
511
+ 'INSERT INTO CHANNEL (NAME, ADMIN_ONLY) VALUES (?, 0)', (name, ))
512
+ return Response(status=200)
513
+ elif request.method == 'PUT':
514
+ if 'sw_priv' in request.args.keys():
515
+ id = int(request.args['sw_priv'])
516
+ conn.execute(
517
+ 'UPDATE CHANNEL SET ADMIN_ONLY = 1 - ADMIN_ONLY WHERE ID=?;',
518
+ (id,)
519
+ )
520
+ return Response(status=200)
521
+ elif 'name' in request.args.keys() and 'id' in request.args.keys():
522
+ id = int(request.args['id'])
523
+ name = request.args['name']
524
+ conn.execute(
525
+ 'UPDATE CHANNEL SET NAME=? WHERE ID=?;',
526
+ (name, id)
527
+ )
528
+ return Response(status=200)
529
+ elif request.method == 'DELETE':
530
+ if 'id' in request.args.keys():
531
+ id = request.args['id']
532
+ conn.execute(
533
+ 'UPDATE CHANNEL SET IS_DELETED=1 WHERE ID=?;',
534
+ (id, )
535
+ )
536
+ return Response(status=200)
537
+ return Response(status=404)
538
+
539
+
540
+ @app.route('/online/<name>', methods=['DELETE'])
541
+ @admin_login_required
542
+ def kick_user(name):
543
+ if name in online.keys():
544
+ clean_user(name)
545
+ return Response(status=200)
546
+ return Response(status=400)
547
+
548
+
549
+ @app.route('/resource/<res_id>', methods=['DELETE'])
550
+ @admin_login_required
551
+ def delete_resource(res_id):
552
+ try:
553
+ conn.execute(
554
+ 'UPDATE RESOURCE SET IS_EXPIRED=1 WHERE UUID=?;',
555
+ (res_id, )
556
+ )
557
+ except Exception as e:
558
+ logger.error(f'Error deleting resource {res_id} with exception {e}')
559
+ return Response(status=400)
560
+ return Response(status=200)
561
+
562
+
563
+ @app.route('/message/<int:id>', methods=['DELETE'])
564
+ @admin_login_required
565
+ def delete_msg(id: int):
566
+ try:
567
+ conn.execute(
568
+ 'UPDATE CHAT SET IS_DELETED=1 WHERE ID=?;',
569
+ (id, )
570
+ )
571
+ except Exception as e:
572
+ logger.error(f'Error deleting msg {id} with exception {e}')
573
+ return Response(status=400)
574
+ return Response(status=200)
575
+
576
+
577
+ # --- SOCKET EVENTS ---
578
+
579
+ @socketio.on('updating_channel')
580
+ def updated_channel():
581
+ emit('channel_updated')
582
+
583
+
584
+ @socketio.on('sw_channel')
585
+ def sw_channel_handler(json):
586
+ try:
587
+ if json['token'] != online[json['nick']]['token']:
588
+ return
589
+ except:
590
+ return
591
+
592
+ user = json['nick']
593
+ to = json['to']
594
+ is_admin = online[user]['is_admin']
595
+ res = conn.execute('SELECT id FROM CHANNEL' +
596
+ (' WHERE ADMIN_ONLY=0;', '')[is_admin])
597
+ ids = [x[0] for x in res.fetchall()]
598
+
599
+ if to not in ids:
600
+ return
601
+
602
+ current_channel = online[user]['channel']
603
+ if current_channel != 0:
604
+ leave_room(current_channel)
605
+ emit('leaving', {'target': json['nick']}, to=current_channel)
606
+ join_room(to)
607
+ online[user]['channel'] = to
608
+ emit('joining', {'target': json['nick']}, to=to)
609
+
610
+
611
+ @socketio.on('msg_send')
612
+ def msg_send_handler(json):
613
+ author: str = json['author']
614
+ token: str = json['token']
615
+ if not author in online.keys():
616
+ return
617
+ if online[author]['token'] != token:
618
+ return
619
+ if json['body'] == '':
620
+ return
621
+ body: str = bbcode.render_html(json['body'])
622
+ attachments: list = json.get('attachments', [])
623
+ time: str = datetime.now(UTC).strftime('%Y-%m-%d %H:%M:%S')
624
+ msg = {
625
+ 'author': author,
626
+ 'datetime': time,
627
+ 'body': body,
628
+ 'attachments': attachments
629
+ }
630
+ cur = conn.execute('INSERT INTO CHAT (BODY, CHANNEL_ID, AUTHOR) VALUES (?,?,?) RETURNING ID;', (
631
+ body,
632
+ online[author]['channel'],
633
+ author
634
+ ))
635
+ (chat_id, ) = cur.fetchone()
636
+ msg.update({'id': chat_id})
637
+ if attachments != []:
638
+ for a in attachments:
639
+ conn.execute(
640
+ 'INSERT INTO ATTACHMENT (CHAT_ID, RESOURCE_ID) VALUES (?,?);', (chat_id, a))
641
+ emit('msg_deliver', msg, to=online[author]['channel'])
642
+
643
+
644
+ @socketio.on('connect')
645
+ def connect_handler(auth):
646
+ try:
647
+ if online[auth['nick']]['token'] != auth['token']:
648
+ raise ConnectionRefusedError('Not Authorized')
649
+ except:
650
+ raise ConnectionRefusedError('Missing Authorization')
651
+
652
+ try:
653
+ # to make static checking happy
654
+ online[auth['nick']]['sid'] = getattr(request, 'sid', None)
655
+ online[auth['nick']]['namespace'] = getattr(request, 'namespace', None)
656
+ except:
657
+ raise ConnectionRefusedError('No sid or namespace')
658
+ else:
659
+ if (time_milisecond() - online[auth['nick']]['last_heartbeat']) > app.config['app']['timeout']:
660
+ clean_user(auth['nick'])
661
+ raise ConnectionRefusedError('Not Authorized')
662
+ if online[auth['nick']]['is_alive']:
663
+ raise ConnectionRefusedError('Duplicate Session')
664
+ logger.info(f'User {auth['nick']} connected.')
665
+ online[auth['nick']]['is_alive'] = True
666
+
667
+
668
+ @socketio.on('disconnect')
669
+ def disconnect_handler():
670
+ nick = None
671
+ for k, v in online.items():
672
+ if v["sid"] == getattr(request, 'sid', None):
673
+ nick = k
674
+ if nick:
675
+ logger.info(f'User {nick} disconnected.')
676
+ online[nick]['is_alive'] = False
677
+ online[nick]['last_heartbeat'] = time_milisecond()
678
+
679
+
680
+ @socketio.on('heartbeat')
681
+ def heartbeat_handler(json):
682
+ try:
683
+ if online[json['nick']]['token'] != json['token']:
684
+ return False
685
+ except:
686
+ return False
687
+ online[json['nick']]['is_alive'] = True
688
+ online[json['nick']]['last_heartbeat'] = time_milisecond()
689
+
690
+
691
+ def exit():
692
+ try:
693
+ conn.close()
694
+ except:
695
+ pass
696
+
697
+
698
+ atexit.register(exit)