pyhttplib 0.0.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.
httplib.py ADDED
@@ -0,0 +1,2089 @@
1
+ from typing import Protocol, Callable, Any, NoReturn, TypeVar, Awaitable, Union, overload
2
+ from traceback import format_exc
3
+ from functools import wraps
4
+ import threading
5
+ import asyncio
6
+ import hashlib
7
+ import socket
8
+ import base64
9
+ import json
10
+ import time
11
+ import hmac
12
+ import os
13
+
14
+ s = socket.socket()
15
+
16
+ # Type variable for the decorated function
17
+ F = TypeVar('F', bound=Callable[..., Awaitable[Any]])
18
+
19
+ class RouteHandler(Protocol):
20
+ async def __call__(self, *args: Any, **kwargs: Any) -> str | bytes | tuple[int, str | bytes] | None | NoReturn: ...
21
+
22
+ class ErrorHandler(Protocol):
23
+ async def __call__(self, *args: Any, **kwargs: Any) -> str | bytes | tuple[int, str | bytes]: ...
24
+
25
+ ## Routing ##
26
+ routes: list[tuple[str, str, RouteHandler]] = []
27
+ errors: dict[int | None, ErrorHandler] = {}
28
+
29
+ ## Websockets ##
30
+ ws_connections: dict[str, list[socket.socket]] = {}
31
+
32
+ ## Logins ##
33
+ users: dict[str, bytes] = {}
34
+ profiles: dict[str, dict[str, Any]] = {}
35
+ sessions: dict[str, dict[str, Any]] = {}
36
+
37
+ ## Rate limiting ##
38
+ # WebSocket rate limiting
39
+ ws_rate_limit_delays: dict[tuple[socket.socket, str], float] = {}
40
+ ws_rate_limit_counts: dict[tuple[socket.socket, str], list[float]] = {}
41
+
42
+ # HTTP rate limiting
43
+ rate_limit_delays: dict[tuple[str, str], float] = {}
44
+ rate_limit_counts: dict[tuple[str, str], list[float]] = {}
45
+ rate_limit_lock = threading.Lock()
46
+
47
+ ## DOS Protection ##
48
+ # Global IP-based DOS protection
49
+ dos_ip_requests: dict[str, list[float]] = {} # IP -> list of request timestamps
50
+ dos_blocked_ips: dict[str, float] = {} # IP -> block expiry time
51
+ dos_lock = threading.Lock()
52
+
53
+ # DOS protection configuration
54
+ DOS_WINDOW_SIZE = 1 # seconds
55
+ DOS_MAX_REQUESTS = 100 # max requests per window per IP
56
+ DOS_BLOCK_DURATION = 3 # seconds to block IP after DOS detection
57
+ DOS_WEBSOCKET_MAX_CONNECTIONS = 100 # max WebSocket connections per IP
58
+
59
+ # WebSocket-specific DOS protection
60
+ ws_dos_violations: dict[socket.socket, int] = {} # socket -> violation count
61
+ ws_last_message: dict[socket.socket, float] = {} # socket -> last message time
62
+ ws_messages_per_second: dict[socket.socket, tuple[float, int]] = {} # socket -> (second_start, count)
63
+ ws_dos_lock = threading.Lock()
64
+
65
+ # WebSocket DOS protection configuration
66
+ DOS_WS_MAX_MESSAGES_PER_SECOND = 100 # max WebSocket messages per second
67
+ DOS_WS_MIN_MESSAGE_INTERVAL = 0.001 # minimum seconds between messages (1ms)
68
+ DOS_WS_MAX_VIOLATIONS = 3 # max violations before disconnection
69
+
70
+ # Simple log function with log levels
71
+ LOG_LEVELS = {"DEBUG": 10, "INFO": 20, "WARNING": 30, "ERROR": 40, "CRITICAL": 50}
72
+ log_level = LOG_LEVELS['INFO']
73
+
74
+ # Refined log level colors (bold, specific colors)
75
+ LOG_LEVEL_COLORS = {
76
+ "DEBUG": "\033[90;1m", # Bold gray
77
+ "INFO": "\033[94;1m", # Bold blue
78
+ "WARNING": "\033[93;1m", # Bold yellow
79
+ "ERROR": "\033[91;1m", # Bold red
80
+ "CRITICAL": "\033[41;97;1m" # Bold white on dark red bg
81
+ }
82
+ RESET_COLOR = "\033[0m"
83
+
84
+ # HTTP method colors for log_request
85
+ METHOD_COLORS = {
86
+ 'GET': '\033[92m', # Green
87
+ 'POST': '\033[96m', # Cyan
88
+ 'PUT': '\033[95m', # Magenta
89
+ 'DELETE': '\033[91m', # Red
90
+ 'PATCH': '\033[93m', # Yellow
91
+ 'OPTIONS': '\033[90m', # Gray
92
+ }
93
+
94
+ def log(message: str, level: str = "INFO") -> None:
95
+ """
96
+ Log a message with a specified level and timestamp.
97
+
98
+ Supports colored output for different log levels: DEBUG, INFO, WARNING, ERROR, CRITICAL.
99
+ Messages are only printed if their level meets the current log level threshold.
100
+
101
+ Args:
102
+ message: The message to log
103
+ level: Log level (DEBUG, INFO, WARNING, ERROR, CRITICAL). Defaults to INFO.
104
+
105
+ Example:
106
+ ```python
107
+ log("Server starting", "INFO")
108
+ log("User authentication failed", "WARNING")
109
+ log("Database connection error", "ERROR")
110
+ ```
111
+ """
112
+ level = level.upper()
113
+ if level not in LOG_LEVELS:
114
+ level = "INFO"
115
+ if LOG_LEVELS[level] >= log_level:
116
+ from datetime import datetime
117
+ ts = datetime.now().strftime('%H:%M:%S')
118
+ color = LOG_LEVEL_COLORS.get(level, "")
119
+
120
+ # Timestamp is plain, log level is bold and colored
121
+ print(f"[{ts}] {color}{level:>8}{RESET_COLOR}: {message}")
122
+
123
+ def log_request(method: str, path: str, status: int | None = None) -> None:
124
+ """
125
+ Log HTTP requests with colored method names.
126
+
127
+ Automatically formats and logs HTTP requests with colored method names for better readability.
128
+ Each HTTP method has its own color (GET=green, POST=cyan, etc.).
129
+
130
+ Args:
131
+ method: HTTP method (GET, POST, PUT, DELETE, etc.)
132
+ path: Request path
133
+ status: Optional HTTP status code to include in the log
134
+
135
+ Example:
136
+ ```python
137
+ log_request("GET", "/api/users")
138
+ log_request("POST", "/login", 200)
139
+ ```
140
+ """
141
+ method = method.upper()
142
+ color = METHOD_COLORS.get(method, '')
143
+ reset = RESET_COLOR if color else ''
144
+ status_str = f" {status}" if status is not None else ""
145
+ log(f"{color}{method}{reset} {path}{status_str}", "INFO")
146
+
147
+ # Request context
148
+ class Request:
149
+ def __init__(self):
150
+ self.headers: dict[str, str] = {}
151
+ self.params: dict[str, str] = {}
152
+ self.cookies: dict[str, str] = {}
153
+ self.path: str = ''
154
+ self.method: str = ''
155
+ self.body: str = ''
156
+ self.client_ip: str = ''
157
+
158
+ # Response context for managing cookies
159
+ class Response:
160
+ def __init__(self):
161
+ self.cookies: dict[str, dict[str, str]] = {} # cookie_name -> {value, path, domain, expires, etc}
162
+
163
+ def set_cookie(self, name: str, value: str, path: str = '/', domain: str = '',
164
+ expires: str = '', max_age: int = 0, secure: bool = False,
165
+ httponly: bool = False, samesite: str = ''):
166
+ """Set a cookie with optional attributes"""
167
+ cookie_attrs = {'value': value}
168
+ if path: cookie_attrs['path'] = path
169
+ if domain: cookie_attrs['domain'] = domain
170
+ if expires: cookie_attrs['expires'] = expires
171
+ if max_age > 0: cookie_attrs['max_age'] = str(max_age)
172
+ if secure: cookie_attrs['secure'] = 'true'
173
+ if httponly: cookie_attrs['httponly'] = 'true'
174
+ if samesite: cookie_attrs['samesite'] = samesite
175
+
176
+ self.cookies[name] = cookie_attrs
177
+
178
+ def delete_cookie(self, name: str, path: str = '/'):
179
+ """Delete a cookie by setting it to expire in the past"""
180
+ self.set_cookie(name, '', path=path, expires='Thu, 01 Jan 1970 00:00:00 GMT')
181
+
182
+ def get_cookie_headers(self) -> list[str]:
183
+ """Generate Set-Cookie headers for all cookies"""
184
+ headers: list[str] = []
185
+ for name, attrs in self.cookies.items():
186
+ cookie_header = f"{name}={attrs['value']}"
187
+
188
+ for attr_name, attr_value in attrs.items():
189
+ if attr_name == 'value':
190
+ continue
191
+ elif attr_name in ['secure', 'httponly']:
192
+ if attr_value == 'true':
193
+ cookie_header += f"; {attr_name.replace('httponly', 'HttpOnly').replace('secure', 'Secure')}"
194
+ elif attr_name == 'max_age':
195
+ cookie_header += f"; Max-Age={attr_value}"
196
+ elif attr_name == 'samesite':
197
+ cookie_header += f"; SameSite={attr_value}"
198
+ else:
199
+ cookie_header += f"; {attr_name.capitalize()}={attr_value}"
200
+
201
+ headers.append(cookie_header)
202
+ return headers
203
+
204
+ # Use thread-local storage for safety with threads
205
+ _request_ctx = threading.local()
206
+ _response_ctx = threading.local()
207
+
208
+ def _get_request() -> Request:
209
+ if not hasattr(_request_ctx, 'request'):
210
+ _request_ctx.request = Request()
211
+ return _request_ctx.request
212
+
213
+ def _get_response() -> Response:
214
+ if not hasattr(_response_ctx, 'response'):
215
+ _response_ctx.response = Response()
216
+ return _response_ctx.response
217
+
218
+ # Flask-like global request and response objects
219
+ request = _get_request()
220
+ response = _get_response()
221
+
222
+ # Helpers
223
+ async def _normalize_path(path: str) -> str:
224
+ # Remove query string if present
225
+ path = path.split('?', 1)[0]
226
+
227
+ # Default to index.html for root
228
+ if path in ('', '/'):
229
+ path = '/index.html'
230
+
231
+ # Prevent directory traversal
232
+ path = os.path.normpath(path).replace('\\', '/')
233
+ if '..' in path or path.startswith('../') or path.startswith('/..'):
234
+ path = '/index.html'
235
+
236
+ # Ensure path starts with /static unless already present
237
+ if not path.removeprefix('/').startswith('static/'):
238
+ # Remove leading slash to avoid double slash
239
+ path = '/static' + (path if path.startswith('/') else f'/{path}')
240
+
241
+ # Ensure .html extension
242
+ if '.' not in path:
243
+ path += '.html'
244
+
245
+ return path.removeprefix('/')
246
+
247
+ # Parsers
248
+ async def _parse_headers(headers: str) -> dict[str, str]:
249
+ header_dict: dict[str, str] = {}
250
+ for line in headers.split('\n'):
251
+ line = line.strip()
252
+ if line:
253
+ key, value = line.split(':', 1)
254
+ header_dict[key.strip()] = value.strip()
255
+ return header_dict
256
+
257
+ async def _parse_params(query: str) -> dict[str,str]:
258
+ params:dict[str,str] = {}
259
+ if query:
260
+ for param in query.split('&'):
261
+ key, value = param.split('=', 1)
262
+ params[key] = value
263
+ return params
264
+
265
+ async def _parse_cookies(cookie_header: str) -> dict[str, str]:
266
+ """Parse cookies from Cookie header"""
267
+ cookies: dict[str, str] = {}
268
+ if not cookie_header:
269
+ return cookies
270
+
271
+ # Split cookies by semicolon and parse each one
272
+ for cookie in cookie_header.split(';'):
273
+ cookie = cookie.strip()
274
+ if '=' in cookie:
275
+ name, value = cookie.split('=', 1)
276
+ cookies[name.strip()] = value.strip()
277
+
278
+ return cookies
279
+
280
+ # Urlencode
281
+ async def url_decode(value: str) -> str:
282
+ """
283
+ Decode a URL-encoded string.
284
+
285
+ Converts percent-encoded characters (%20, %21, etc.) back to their original characters.
286
+ Also converts '+' characters to spaces (common in form data).
287
+
288
+ Args:
289
+ value: URL-encoded string to decode
290
+
291
+ Returns:
292
+ Decoded string
293
+
294
+ Example:
295
+ ```python
296
+ decoded = await url_decode("Hello%20World%21") # "Hello World!"
297
+ form_data = await url_decode("first+name") # "first name"
298
+ ```
299
+ """
300
+ # Replace + with space (optional, for form data)
301
+ value = value.replace('+', ' ')
302
+ # Find all percent-encoded bytes
303
+ bytes_list = bytearray()
304
+ i = 0
305
+ while i < len(value):
306
+ if value[i] == '%' and i + 2 < len(value):
307
+ bytes_list.append(int(value[i+1:i+3], 16))
308
+ i += 3
309
+ else:
310
+ bytes_list.append(ord(value[i]))
311
+ i += 1
312
+
313
+ return bytes_list.decode('utf-8')
314
+
315
+ async def url_encode(value: str) -> str:
316
+ """
317
+ Encode a string for safe use in URLs.
318
+
319
+ Converts special characters to percent-encoded format (%20, %21, etc.).
320
+ Alphanumeric characters and -_.~ are left unchanged as they're safe.
321
+
322
+ Args:
323
+ value: String to encode
324
+
325
+ Returns:
326
+ URL-encoded string
327
+
328
+ Example:
329
+ ```python
330
+ encoded = await url_encode("Hello World!") # "Hello%20World%21"
331
+ safe_param = await url_encode("user@domain.com") # "user%40domain.com"
332
+ ```
333
+ """
334
+ encoded:list[str] = []
335
+ for char in value:
336
+ if char.isalnum() or char in '-_.~':
337
+ encoded.append(char)
338
+ else:
339
+ encoded.append(f'%{ord(char):02X}')
340
+ return ''.join(encoded)
341
+
342
+ async def get_error_shorthand(err_code: int) -> str:
343
+ """
344
+ Get the standard HTTP status text for a given status code.
345
+
346
+ Returns the official HTTP status message for common status codes (200 OK, 404 Not Found, etc.).
347
+
348
+ Args:
349
+ err_code: HTTP status code
350
+
351
+ Returns:
352
+ Status text string, or 'Unknown Error' for unrecognized codes
353
+
354
+ Example:
355
+ ```python
356
+ text = await get_error_shorthand(200) # "OK"
357
+ text = await get_error_shorthand(404) # "Not Found"
358
+ text = await get_error_shorthand(500) # "Internal Server Error"
359
+ ```
360
+ """
361
+ shorthand = {
362
+ 100: 'Continue',
363
+ 101: 'Switching Protocols',
364
+ 102: 'Processing',
365
+ 103: 'Early Hints',
366
+ 200: 'OK',
367
+ 201: 'Created',
368
+ 202: 'Accepted',
369
+ 203: 'Non-Authoritative Information',
370
+ 204: 'No Content',
371
+ 205: 'Reset Content',
372
+ 206: 'Partial Content',
373
+ 207: 'Multi-Status',
374
+ 208: 'Already Reported',
375
+ 226: 'IM Used',
376
+ 300: 'Multiple Choices',
377
+ 301: 'Moved Permanently',
378
+ 302: 'Found',
379
+ 303: 'See Other',
380
+ 304: 'Not Modified',
381
+ 305: 'Use Proxy',
382
+ 307: 'Temporary Redirect',
383
+ 308: 'Permanent Redirect',
384
+ 400: 'Bad Request',
385
+ 401: 'Unauthorized',
386
+ 402: 'Payment Required',
387
+ 403: 'Forbidden',
388
+ 404: 'Not Found',
389
+ 405: 'Method Not Allowed',
390
+ 406: 'Not Acceptable',
391
+ 407: 'Proxy Authentication Required',
392
+ 408: 'Request Timeout',
393
+ 409: 'Conflict',
394
+ 410: 'Gone',
395
+ 411: 'Length Required',
396
+ 412: 'Precondition Failed',
397
+ 413: 'Payload Too Large',
398
+ 414: 'URI Too Long',
399
+ 415: 'Unsupported Media Type',
400
+ 416: 'Range Not Satisfiable',
401
+ 417: 'Expectation Failed',
402
+ 418: "I'm a teapot",
403
+ 421: 'Misdirected Request',
404
+ 422: 'Unprocessable Entity',
405
+ 423: 'Locked',
406
+ 424: 'Failed Dependency',
407
+ 425: 'Too Early',
408
+ 426: 'Upgrade Required',
409
+ 428: 'Precondition Required',
410
+ 429: 'Too Many Requests',
411
+ 431: 'Request Header Fields Too Large',
412
+ 451: 'Unavailable For Legal Reasons',
413
+ 500: 'Internal Server Error',
414
+ 501: 'Not Implemented',
415
+ 502: 'Bad Gateway',
416
+ 503: 'Service Unavailable',
417
+ 504: 'Gateway Timeout',
418
+ 505: 'HTTP Version Not Supported',
419
+ 506: 'Variant Also Negotiates',
420
+ 507: 'Insufficient Storage',
421
+ 508: 'Loop Detected',
422
+ 510: 'Not Extended',
423
+ 511: 'Network Authentication Required'
424
+ }
425
+ return shorthand.get(err_code, 'Unknown Error')
426
+
427
+ async def get_content_type(path: str) -> str:
428
+ """
429
+ Determine the MIME content type based on file extension.
430
+
431
+ Returns the appropriate Content-Type header value for common file extensions.
432
+
433
+ Args:
434
+ path: File path or filename
435
+
436
+ Returns:
437
+ MIME type string (e.g., 'text/html', 'application/json')
438
+
439
+ Example:
440
+ ```python
441
+ content_type = await get_content_type("index.html") # "text/html"
442
+ content_type = await get_content_type("api.json") # "application/json"
443
+ content_type = await get_content_type("style.css") # "text/css"
444
+ ```
445
+ """
446
+ if path.endswith('.html'):
447
+ return 'text/html'
448
+ elif path.endswith('.css'):
449
+ return 'text/css'
450
+ elif path.endswith('.js'):
451
+ return 'application/javascript'
452
+ elif path.endswith('.png'):
453
+ return 'image/png'
454
+ elif path.endswith('.jpg') or path.endswith('.jpeg'):
455
+ return 'image/jpeg'
456
+ elif path.endswith('.gif'):
457
+ return 'image/gif'
458
+ elif path.endswith('.svg'):
459
+ return 'image/svg+xml'
460
+ elif path.endswith('.json'):
461
+ return 'application/json'
462
+ elif path.endswith('.txt'):
463
+ return 'text/plain'
464
+ elif path.endswith('.xml'):
465
+ return 'application/xml'
466
+ else:
467
+ return 'text/plain'
468
+
469
+ def get_client_ip() -> str:
470
+ """
471
+ Extract the client IP address from the current request context.
472
+
473
+ Attempts to get the real client IP by checking multiple sources in order:
474
+ 1. Direct client IP from socket connection
475
+ 2. X-Forwarded-For header (for proxy scenarios)
476
+ 3. X-Real-IP header
477
+ 4. Falls back to 'unknown' if none available
478
+
479
+ Returns:
480
+ Client IP address as string, or 'unknown' if unavailable
481
+
482
+ Example:
483
+ ```python
484
+ @route('/api/user-info')
485
+ async def user_info():
486
+ client_ip = get_client_ip()
487
+ return f"Your IP is: {client_ip}"
488
+ ```
489
+ """
490
+ try:
491
+ req = _get_request()
492
+
493
+ # First check if we have the actual client IP from the socket
494
+ if req.client_ip:
495
+ return req.client_ip
496
+
497
+ # Try to get from X-Forwarded-For header
498
+ forwarded = req.headers.get('X-Forwarded-For', '')
499
+ if forwarded:
500
+ return forwarded.split(',')[0].strip()
501
+
502
+ # Try X-Real-IP
503
+ real_ip = req.headers.get('X-Real-IP', '')
504
+ if real_ip:
505
+ return real_ip
506
+
507
+ # Fallback to a default
508
+ return 'unknown'
509
+ except Exception:
510
+ return 'unknown'
511
+
512
+ def is_ip_blocked(ip: str) -> bool:
513
+ """
514
+ Check if an IP address is currently blocked due to DOS protection.
515
+
516
+ Args:
517
+ ip: IP address to check
518
+
519
+ Returns:
520
+ True if IP is blocked, False otherwise
521
+ """
522
+ if ip == 'unknown':
523
+ return False
524
+
525
+ current_time = time.time()
526
+
527
+ with dos_lock:
528
+ # Clean up expired blocks
529
+ expired_ips = [blocked_ip for blocked_ip, expiry in dos_blocked_ips.items()
530
+ if current_time > expiry]
531
+ for blocked_ip in expired_ips:
532
+ del dos_blocked_ips[blocked_ip]
533
+ log(f"Unblocked IP {blocked_ip} (block expired)", "INFO")
534
+
535
+ # Check if IP is currently blocked
536
+ if ip in dos_blocked_ips:
537
+ remaining_time = dos_blocked_ips[ip] - current_time
538
+ log(f"Blocked IP {ip} attempted request (blocked for {remaining_time:.1f}s more)", "WARNING")
539
+ return True
540
+
541
+ return False
542
+
543
+ def check_dos_protection(ip: str) -> bool:
544
+ """
545
+ Check if an IP should be blocked for DOS protection.
546
+
547
+ Args:
548
+ ip: IP address to check
549
+
550
+ Returns:
551
+ True if IP should be blocked, False if allowed
552
+ """
553
+ if ip == 'unknown':
554
+ return False
555
+
556
+ current_time = time.time()
557
+
558
+ with dos_lock:
559
+ # Initialize request history for new IPs
560
+ if ip not in dos_ip_requests:
561
+ dos_ip_requests[ip] = []
562
+
563
+ # Clean old requests outside the window
564
+ window_start = current_time - DOS_WINDOW_SIZE
565
+ dos_ip_requests[ip] = [req_time for req_time in dos_ip_requests[ip]
566
+ if req_time > window_start]
567
+
568
+ # Add current request
569
+ dos_ip_requests[ip].append(current_time)
570
+
571
+ # Check if DOS threshold exceeded
572
+ if len(dos_ip_requests[ip]) > DOS_MAX_REQUESTS:
573
+ # Block the IP
574
+ dos_blocked_ips[ip] = current_time + DOS_BLOCK_DURATION
575
+ log(f"DOS protection activated: blocked IP {ip} for {DOS_BLOCK_DURATION}s ({len(dos_ip_requests[ip])} requests in {DOS_WINDOW_SIZE}s)", "ERROR")
576
+
577
+ # Clear request history to prevent memory bloat
578
+ dos_ip_requests[ip] = []
579
+ return True
580
+
581
+ return False
582
+
583
+ def count_websocket_connections(ip: str) -> int:
584
+ """
585
+ Count active WebSocket connections for an IP address.
586
+
587
+ Args:
588
+ ip: IP address to count connections for
589
+
590
+ Returns:
591
+ Number of active WebSocket connections for this IP
592
+ """
593
+ count = 0
594
+ for route_connections in ws_connections.values():
595
+ for sock in route_connections:
596
+ try:
597
+ peer_addr = sock.getpeername()
598
+ if peer_addr and peer_addr[0] == ip:
599
+ count += 1
600
+ except Exception:
601
+ # Socket might be closed, ignore
602
+ pass
603
+ return count
604
+
605
+ def unblock_ip(ip: str) -> bool:
606
+ """
607
+ Manually unblock an IP address.
608
+
609
+ Args:
610
+ ip: IP address to unblock
611
+
612
+ Returns:
613
+ True if IP was blocked and is now unblocked, False if IP wasn't blocked
614
+ """
615
+ with dos_lock:
616
+ if ip in dos_blocked_ips:
617
+ del dos_blocked_ips[ip]
618
+ log(f"Manually unblocked IP {ip}", "INFO")
619
+ return True
620
+ return False
621
+
622
+ def get_blocked_ips() -> dict[str, float]:
623
+ """
624
+ Get all currently blocked IPs and their unblock times.
625
+
626
+ Returns:
627
+ Dictionary mapping IP addresses to their unblock timestamps
628
+ """
629
+ current_time = time.time()
630
+ with dos_lock:
631
+ # Clean up expired blocks first
632
+ expired_ips = [blocked_ip for blocked_ip, expiry in dos_blocked_ips.items()
633
+ if current_time > expiry]
634
+ for blocked_ip in expired_ips:
635
+ del dos_blocked_ips[blocked_ip]
636
+
637
+ # Return copy of current blocked IPs
638
+ return dos_blocked_ips.copy()
639
+
640
+ def get_dos_stats() -> dict[str, Any]:
641
+ """
642
+ Get DOS protection statistics.
643
+
644
+ Returns:
645
+ Dictionary with DOS protection statistics
646
+ """
647
+ current_time = time.time()
648
+ with dos_lock:
649
+ # Clean up old request history
650
+ total_tracked_ips = len(dos_ip_requests)
651
+ active_requests = 0
652
+
653
+ for requests in dos_ip_requests.values():
654
+ # Count recent requests (within window)
655
+ recent_requests = [req_time for req_time in requests
656
+ if current_time - req_time < DOS_WINDOW_SIZE]
657
+ active_requests += len(recent_requests)
658
+
659
+ blocked_count = len(dos_blocked_ips)
660
+
661
+ # Count total WebSocket connections
662
+ total_ws_connections = sum(len(conns) for conns in ws_connections.values())
663
+
664
+ # WebSocket DOS stats
665
+ with ws_dos_lock:
666
+ ws_tracked_sockets = len(ws_dos_violations)
667
+ ws_total_violations = sum(ws_dos_violations.values())
668
+
669
+ return {
670
+ 'tracked_ips': total_tracked_ips,
671
+ 'active_requests_in_window': active_requests,
672
+ 'blocked_ips': blocked_count,
673
+ 'total_websocket_connections': total_ws_connections,
674
+ 'ws_tracked_sockets': ws_tracked_sockets,
675
+ 'ws_total_violations': ws_total_violations,
676
+ 'window_size_seconds': DOS_WINDOW_SIZE,
677
+ 'max_requests_per_window': DOS_MAX_REQUESTS,
678
+ 'block_duration_seconds': DOS_BLOCK_DURATION,
679
+ 'max_websocket_connections_per_ip': DOS_WEBSOCKET_MAX_CONNECTIONS,
680
+ 'ws_max_messages_per_second': DOS_WS_MAX_MESSAGES_PER_SECOND,
681
+ 'ws_min_message_interval': DOS_WS_MIN_MESSAGE_INTERVAL,
682
+ 'ws_max_violations': DOS_WS_MAX_VIOLATIONS
683
+ }
684
+
685
+ def _cleanup_websocket_dos_tracking(sock: socket.socket):
686
+ """Clean up DOS tracking for a closed WebSocket."""
687
+ with ws_dos_lock:
688
+ if sock in ws_dos_violations:
689
+ del ws_dos_violations[sock]
690
+ if sock in ws_last_message:
691
+ del ws_last_message[sock]
692
+ if sock in ws_messages_per_second:
693
+ del ws_messages_per_second[sock]
694
+
695
+ def _check_websocket_dos_protection(sock: socket.socket) -> bool:
696
+ """
697
+ Check WebSocket DOS protection for a specific socket.
698
+
699
+ Args:
700
+ sock: WebSocket socket to check
701
+
702
+ Returns:
703
+ True if should disconnect due to DOS protection, False if allowed
704
+ """
705
+ current_time = time.time()
706
+
707
+ # Get client IP for additional blocking
708
+ try:
709
+ client_addr = sock.getpeername()
710
+ client_ip = client_addr[0] if client_addr else 'unknown'
711
+ except Exception:
712
+ client_ip = 'unknown'
713
+
714
+ # Check if IP is globally blocked
715
+ if is_ip_blocked(client_ip):
716
+ log(f"WebSocket message blocked: IP {client_ip} is globally blocked", "WARNING")
717
+ return True
718
+
719
+ with ws_dos_lock:
720
+ # Initialize tracking for new sockets
721
+ if sock not in ws_dos_violations:
722
+ ws_dos_violations[sock] = 0
723
+ if sock not in ws_last_message:
724
+ ws_last_message[sock] = 0.0
725
+ if sock not in ws_messages_per_second:
726
+ ws_messages_per_second[sock] = (current_time, 0)
727
+
728
+ # Check message frequency (messages per second)
729
+ second_start, message_count = ws_messages_per_second[sock]
730
+ if current_time - second_start >= 1.0:
731
+ # Reset counter every second
732
+ ws_messages_per_second[sock] = (current_time, 1)
733
+ else:
734
+ # Increment message count
735
+ message_count += 1
736
+ ws_messages_per_second[sock] = (second_start, message_count)
737
+
738
+ # Check if exceeding messages per second limit
739
+ if message_count > DOS_WS_MAX_MESSAGES_PER_SECOND:
740
+ ws_dos_violations[sock] += 1
741
+ log(f"WebSocket DOS: {client_ip} exceeded {DOS_WS_MAX_MESSAGES_PER_SECOND} messages/second (violation #{ws_dos_violations[sock]})", "WARNING")
742
+
743
+ if ws_dos_violations[sock] >= DOS_WS_MAX_VIOLATIONS:
744
+ log(f"WebSocket DOS: disconnecting {client_ip} after {ws_dos_violations[sock]} violations", "ERROR")
745
+ # Also trigger IP-level DOS protection
746
+ check_dos_protection(client_ip)
747
+ return True
748
+ return False # Just warn, don't disconnect yet
749
+
750
+ # Check minimum interval between messages
751
+ last_time = ws_last_message[sock]
752
+ if last_time > 0 and (current_time - last_time) < DOS_WS_MIN_MESSAGE_INTERVAL:
753
+ ws_dos_violations[sock] += 1
754
+ log(f"WebSocket DOS: {client_ip} messages too frequent, {current_time - last_time:.3f}s interval (violation #{ws_dos_violations[sock]})", "WARNING")
755
+
756
+ if ws_dos_violations[sock] >= DOS_WS_MAX_VIOLATIONS:
757
+ log(f"WebSocket DOS: disconnecting {client_ip} for rapid-fire messages", "ERROR")
758
+ check_dos_protection(client_ip)
759
+ return True
760
+ return False # Just warn, don't disconnect yet
761
+
762
+ # Update last message time
763
+ ws_last_message[sock] = current_time
764
+
765
+ # Reduce violation count on good behavior (every 10 good messages)
766
+ if ws_dos_violations[sock] > 0 and message_count <= 3 and int(current_time) % 10 == 0:
767
+ ws_dos_violations[sock] = max(0, ws_dos_violations[sock] - 1)
768
+
769
+ return False # Allow message
770
+
771
+ # Handlers
772
+ async def _handle_request(method: str, path: str, version: str, raw_headers: str, body: str = '', client_ip: str = '') -> bytes:
773
+ if version != 'HTTP/1.1':
774
+ return b'HTTP/1.1 505 HTTP Version Not Supported\r\n\r\n'
775
+
776
+ # DOS Protection - check if IP is blocked
777
+ if is_ip_blocked(client_ip):
778
+ return b'HTTP/1.1 429 Too Many Requests\r\nContent-Type: text/plain\r\n\r\nIP temporarily blocked due to suspicious activity'
779
+
780
+ # DOS Protection - check for abuse
781
+ if check_dos_protection(client_ip):
782
+ return b'HTTP/1.1 429 Too Many Requests\r\nContent-Type: text/plain\r\n\r\nToo many requests - IP blocked temporarily'
783
+
784
+ path, params = path.split('?', 1) if '?' in path else (path, '')
785
+ path = await url_decode(path)
786
+ headers = await _parse_headers(raw_headers)
787
+ params = await _parse_params(params)
788
+
789
+ # Update request context
790
+ req = _get_request()
791
+ req.headers = headers
792
+ req.params = params
793
+ req.cookies = await _parse_cookies(headers.get('Cookie', ''))
794
+ req.path = path
795
+ req.method = method.upper()
796
+ req.body = body
797
+ req.client_ip = client_ip
798
+
799
+ # Iter routes
800
+ for route_path, route_method, func in routes:
801
+ # Only handle non-WebSocket routes here
802
+ if route_method == method.upper() and route_method != 'ws':
803
+ path_vars = await _match_route(path, route_path)
804
+ if path_vars is not None:
805
+ try:
806
+ out = await func(**path_vars)
807
+ finally:
808
+ # Reset request context after handler (but keep response context for now)
809
+ req.headers = {}
810
+ req.params = {}
811
+ req.cookies = {}
812
+ req.path = ''
813
+ req.method = ''
814
+ if hasattr(req, 'body'):
815
+ del req.body
816
+
817
+ if isinstance(out, tuple) and len(out) == 2:
818
+ status_code, content = out
819
+ # Check for error handler
820
+ handler = errors.get(status_code) or errors.get(None)
821
+ if handler:
822
+ # If handler is for all errors, pass status_code as argument
823
+ if None in errors and handler == errors[None]:
824
+ out = await handler(status_code)
825
+ else:
826
+ out = await handler()
827
+ if isinstance(out, tuple) and len(out) == 2:
828
+ status_code, content = out
829
+ else:
830
+ content = out
831
+
832
+ # Determine content type and serialize content for error handler
833
+ if isinstance(content, (dict, list)):
834
+ content = json.dumps(content).encode()
835
+ content_type = 'application/json'
836
+ elif isinstance(content, str):
837
+ content = content.encode()
838
+ content_type = 'text/html'
839
+ elif isinstance(content, bytes):
840
+ content_type = 'text/html'
841
+ else:
842
+ content = str(content).encode()
843
+ content_type = 'text/html'
844
+
845
+ header = await _build_response(status_code, content_type)
846
+
847
+ # Reset response context after building response
848
+ _response_ctx.response = Response()
849
+ return header + content
850
+ else:
851
+ content = out
852
+
853
+ # Determine content type and serialize content
854
+ if isinstance(content, (dict, list)):
855
+ # Serialize dict/list to proper JSON
856
+ content = json.dumps(content).encode()
857
+ content_type = 'application/json'
858
+ elif isinstance(content, str):
859
+ content = content.encode()
860
+ content_type = 'text/html'
861
+ elif isinstance(content, bytes):
862
+ content_type = 'text/html'
863
+ else:
864
+ content = str(content).encode()
865
+ content_type = 'text/html'
866
+
867
+ header = await _build_response(200, content_type)
868
+
869
+ # Reset response context after building response
870
+ _response_ctx.response = Response()
871
+ return header + content
872
+
873
+ # 404 Not Found
874
+ status_code = 404
875
+ handler = errors.get(status_code) or errors.get(None)
876
+ if handler:
877
+ if None in errors and handler == errors[None]:
878
+ out = await handler(status_code)
879
+ else:
880
+ out = await handler()
881
+ if isinstance(out, tuple) and len(out) == 2:
882
+ status_code, content = out
883
+ else:
884
+ content = out
885
+
886
+ # Determine content type and serialize content
887
+ if isinstance(content, (dict, list)):
888
+ # Serialize dict/list to proper JSON
889
+ content = json.dumps(content).encode()
890
+ content_type = 'application/json'
891
+ elif isinstance(content, str):
892
+ content = content.encode()
893
+ content_type = 'text/html'
894
+ elif isinstance(content, bytes):
895
+ content_type = 'text/html'
896
+ else:
897
+ content = str(content).encode()
898
+ content_type = 'text/html'
899
+
900
+ header = await _build_response(status_code, content_type)
901
+
902
+ # Reset response context after building response
903
+ _response_ctx.response = Response()
904
+ return header + content
905
+
906
+ return b'HTTP/1.1 404 Not Found\r\n\r\n'
907
+
908
+ # Socket Handler
909
+ async def _csHandler(cs: socket.socket, addr: tuple[str, int]): # pragma: no cover
910
+ cs.setblocking(False) # Make client socket non-blocking for async operation
911
+ try:
912
+ request_data = await _receive_request(cs)
913
+ if not request_data:
914
+ return
915
+
916
+ method, path, version, headers_str, body = await _parse_http_request(request_data)
917
+ log_request(method, path)
918
+
919
+ if await _is_websocket_request(method, request_data):
920
+ await _handle_websocket(cs, path, request_data)
921
+ else:
922
+ await _handle_http_request(cs, method, path, version, headers_str, body, addr)
923
+
924
+ except Exception as e:
925
+ log(f"Error handling request: {e}", "ERROR")
926
+ finally:
927
+ cs.close()
928
+
929
+ # Request handling
930
+ async def _receive_request(cs: socket.socket) -> bytes: # pragma: no cover
931
+ """Receive complete HTTP request data."""
932
+ loop = asyncio.get_event_loop()
933
+ data = b''
934
+ while True:
935
+ try:
936
+ chunk = await loop.sock_recv(cs, 4096)
937
+ if chunk == b'':
938
+ break
939
+
940
+ data += chunk
941
+ if len(chunk) < 4096:
942
+ break
943
+
944
+ except Exception:
945
+ break
946
+ return data
947
+
948
+ async def _parse_http_request(data: bytes) -> tuple[str, str, str, str, str]: # pragma: no cover
949
+ """Parse HTTP request into components."""
950
+ header_split = data.split(b'\r\n\r\n', 1)
951
+ headers_part = header_split[0]
952
+ body_bytes = header_split[1] if len(header_split) == 2 else b''
953
+
954
+ headers_lines = headers_part.split(b'\r\n')
955
+ request_line = headers_lines[0].decode(errors='ignore')
956
+ headers_str = b'\r\n'.join(headers_lines[1:]).decode(errors='ignore')
957
+
958
+ method, path, version = request_line.split(' ', 2)
959
+ method = method.strip().upper()
960
+
961
+ # Handle request body for POST/PUT/PATCH
962
+ if method in ('POST', 'PUT', 'PATCH'):
963
+ body_bytes = await _read_request_body(headers_str, body_bytes)
964
+
965
+ body = body_bytes.decode(errors='ignore')
966
+ return method, path, version, headers_str, body
967
+
968
+ async def _read_request_body(headers_str: str, initial_body: bytes) -> bytes:
969
+ """Read complete request body based on Content-Length."""
970
+ content_length = 0
971
+ for line in headers_str.split('\r\n'):
972
+ if line.lower().startswith('content-length:'):
973
+ try:
974
+ content_length = int(line.split(':', 1)[1].strip())
975
+ break
976
+ except (ValueError, IndexError):
977
+ pass
978
+
979
+ # Return initial body if it's already complete
980
+ if len(initial_body) >= content_length:
981
+ return initial_body[:content_length]
982
+
983
+ return initial_body
984
+
985
+ async def _parse_headers_bytes(data: bytes) -> dict[bytes, bytes]:
986
+ """Parse HTTP headers from raw bytes."""
987
+ headers_lines = data.split(b'\r\n\r\n', 1)[0].split(b'\r\n')[1:]
988
+ header_dict: dict[bytes, bytes] = {}
989
+
990
+ for line in headers_lines:
991
+ if b':' in line:
992
+ key, value = line.split(b':', 1)
993
+ header_dict[key.strip().lower()] = value.strip()
994
+
995
+ return header_dict
996
+
997
+ async def _handle_http_request(cs: socket.socket, method: str, path: str,
998
+ version: str, headers_str: str, body: str, addr: tuple[str, int]):
999
+ """Handle regular HTTP request."""
1000
+ loop = asyncio.get_event_loop()
1001
+ client_ip = addr[0] # Extract IP from address tuple
1002
+ response = await _handle_request(method, path, version, headers_str, body, client_ip)
1003
+ await loop.sock_sendall(cs, response)
1004
+
1005
+ async def _match_route(request_path: str, route_path: str) -> dict[str, str] | None:
1006
+ req_parts = request_path.strip('/').split('/')
1007
+ route_parts = route_path.strip('/').split('/')
1008
+ if len(req_parts) != len(route_parts):
1009
+ return None
1010
+
1011
+ path_vars: dict[str, str] = {}
1012
+
1013
+ for req, route in zip(req_parts, route_parts):
1014
+ if route.startswith('<') and route.endswith('>'):
1015
+ path_vars[route[1:-1]] = req
1016
+ elif req != route:
1017
+ return None
1018
+
1019
+ return path_vars
1020
+
1021
+ async def _build_response(status_code: int, content_type: str = 'text/html') -> bytes:
1022
+ """Build HTTP response header with cookies"""
1023
+ resp = _get_response()
1024
+ status_text = await get_error_shorthand(status_code)
1025
+
1026
+ header_lines = [
1027
+ f'HTTP/1.1 {status_code} {status_text}',
1028
+ f'Content-Type: {content_type}'
1029
+ ]
1030
+
1031
+ # Add cookie headers
1032
+ cookie_headers = resp.get_cookie_headers()
1033
+ log(f'Response has {len(resp.cookies)} cookies', 'DEBUG') # Debug output
1034
+ log(f'Cookie headers generated: {cookie_headers}', 'DEBUG') # Debug output
1035
+ for cookie_header in cookie_headers:
1036
+ header_lines.append(f'Set-Cookie: {cookie_header}')
1037
+ log(f'Adding Set-Cookie header: {cookie_header}', 'DEBUG') # Debug output
1038
+
1039
+ header_str = '\r\n'.join(header_lines) + '\r\n\r\n'
1040
+ return header_str.encode()
1041
+
1042
+ # WebSocket handling
1043
+ async def _is_websocket_request(method: str, data: bytes) -> bool:
1044
+ """Check if request is a WebSocket upgrade request."""
1045
+ if method != 'GET':
1046
+ return False
1047
+
1048
+ headers = await _parse_headers_bytes(data)
1049
+ return headers.get(b'upgrade', b'').lower() == b'websocket'
1050
+
1051
+ async def _handle_websocket(cs: socket.socket, path: str, data: bytes): # pragma: no cover
1052
+ """Handle WebSocket upgrade and connection."""
1053
+ # Get client IP for DOS protection
1054
+ try:
1055
+ client_addr = cs.getpeername()
1056
+ client_ip = client_addr[0] if client_addr else 'unknown'
1057
+ except Exception:
1058
+ client_ip = 'unknown'
1059
+
1060
+ # DOS Protection - check if IP is blocked
1061
+ if is_ip_blocked(client_ip):
1062
+ loop = asyncio.get_event_loop()
1063
+ await loop.sock_sendall(cs, b'HTTP/1.1 429 Too Many Requests\r\nContent-Type: text/plain\r\n\r\nIP temporarily blocked')
1064
+ return
1065
+
1066
+ # DOS Protection - check WebSocket connection limit
1067
+ current_connections = count_websocket_connections(client_ip)
1068
+ if current_connections >= DOS_WEBSOCKET_MAX_CONNECTIONS:
1069
+ loop = asyncio.get_event_loop()
1070
+ await loop.sock_sendall(cs, b'HTTP/1.1 429 Too Many Requests\r\nContent-Type: text/plain\r\n\r\nToo many WebSocket connections from your IP')
1071
+ log(f"WebSocket connection rejected: IP {client_ip} has {current_connections} connections (limit: {DOS_WEBSOCKET_MAX_CONNECTIONS})", "WARNING")
1072
+ return
1073
+
1074
+ # DOS Protection - check for general abuse
1075
+ if check_dos_protection(client_ip):
1076
+ loop = asyncio.get_event_loop()
1077
+ await loop.sock_sendall(cs, b'HTTP/1.1 429 Too Many Requests\r\nContent-Type: text/plain\r\n\r\nToo many requests - IP blocked temporarily')
1078
+ return
1079
+
1080
+ # Find matching WebSocket route
1081
+ for route_path, route_method, func in routes:
1082
+ if route_method == 'ws' and await _match_route(path, route_path) is not None:
1083
+ await _perform_websocket_handshake(cs, data)
1084
+
1085
+ # Add connection to ws_connections for this route
1086
+ if route_path not in ws_connections:
1087
+ ws_connections[route_path] = []
1088
+ ws_connections[route_path].append(cs)
1089
+ log(f"WebSocket connected from {client_ip} to {route_path}, total: {len(ws_connections[route_path])}", "DEBUG")
1090
+
1091
+ try:
1092
+ await func(cs)
1093
+ finally:
1094
+ # Clean up WebSocket DOS tracking
1095
+ _cleanup_websocket_dos_tracking(cs)
1096
+
1097
+ # Remove connection when handler finishes
1098
+ if route_path in ws_connections and cs in ws_connections[route_path]:
1099
+ ws_connections[route_path].remove(cs)
1100
+ log(f"WebSocket disconnected from {client_ip} ({route_path}), remaining: {len(ws_connections[route_path])}", "DEBUG")
1101
+ # Clean up empty route entries
1102
+ if not ws_connections[route_path]:
1103
+ del ws_connections[route_path]
1104
+ return
1105
+
1106
+ # No matching WebSocket route found
1107
+ loop = asyncio.get_event_loop()
1108
+ await loop.sock_sendall(cs, b'HTTP/1.1 404 Not Found\r\n\r\n')
1109
+
1110
+ async def _perform_websocket_handshake(cs: socket.socket, data: bytes): # pragma: no cover
1111
+ """Perform WebSocket handshake."""
1112
+ import base64
1113
+ import hashlib
1114
+
1115
+ loop = asyncio.get_event_loop()
1116
+ headers = await _parse_headers_bytes(data)
1117
+ key = headers.get(b'sec-websocket-key', b'')
1118
+
1119
+ accept_key = base64.b64encode(
1120
+ hashlib.sha1(key + b"258EAFA5-E914-47DA-95CA-C5AB0DC85B11").digest()
1121
+ )
1122
+
1123
+ handshake_response = (
1124
+ b"HTTP/1.1 101 Switching Protocols\r\n"
1125
+ b"Upgrade: websocket\r\n"
1126
+ b"Connection: Upgrade\r\n"
1127
+ b"Sec-WebSocket-Accept: " + accept_key + b"\r\n\r\n"
1128
+ )
1129
+
1130
+ await loop.sock_sendall(cs, handshake_response)
1131
+
1132
+ # Main loop
1133
+ async def _serve(ip:str, port:int): # pragma: no cover - server main loop
1134
+ s.bind((ip, port))
1135
+ s.listen(5)
1136
+ s.setblocking(False) # Make socket non-blocking for async operation
1137
+
1138
+ loop = asyncio.get_event_loop()
1139
+
1140
+ log(f"Server started on http://{ip}:{port}", "INFO")
1141
+
1142
+ while True:
1143
+ try:
1144
+ cs, addr = await loop.sock_accept(s)
1145
+ asyncio.create_task(_csHandler(cs, addr))
1146
+ except Exception as e:
1147
+ log(f"Error accepting connection: {e}", "ERROR")
1148
+ await asyncio.sleep(0.1)
1149
+
1150
+ def serve(ip: str, port: int = 8080):
1151
+ """
1152
+ Start the HTTP server on the specified IP address and port.
1153
+
1154
+ Runs the asyncio event loop and starts accepting HTTP and WebSocket connections.
1155
+ The server will handle requests until interrupted (Ctrl+C) or an error occurs.
1156
+
1157
+ Args:
1158
+ ip: IP address to bind to (e.g., 'localhost', '0.0.0.0')
1159
+ port: Port number to listen on. Defaults to 8080.
1160
+
1161
+ Example:
1162
+ ```python
1163
+ import http_server as app
1164
+
1165
+ @app.route('/')
1166
+ async def home():
1167
+ return "Hello World!"
1168
+
1169
+ # Start server on localhost:8080
1170
+ app.serve('localhost')
1171
+
1172
+ # Start server on all interfaces, port 3000
1173
+ app.serve('0.0.0.0', 3000)
1174
+ ```
1175
+ """
1176
+ try: asyncio.run(_serve(ip, port))
1177
+ except KeyboardInterrupt:
1178
+ log('Server stopped')
1179
+ except Exception:
1180
+ log(f'{format_exc()}', "ERROR")
1181
+
1182
+ # Content
1183
+ async def template(filename: str, **kwargs: Any) -> str | tuple[int, str]:
1184
+ """
1185
+ Load and render a template file with variable substitution.
1186
+
1187
+ Loads a file from the static directory and replaces template variables in the format
1188
+ {{ variable_name }} with provided values. Returns 404 if file not found.
1189
+
1190
+ Args:
1191
+ filename: Template filename (automatically prefixed with static/ if needed)
1192
+ **kwargs: Variables to substitute in the template
1193
+
1194
+ Returns:
1195
+ Rendered template string, or (404, error_message) tuple if file not found
1196
+
1197
+ Example:
1198
+ ```python
1199
+ @route('/user/<username>')
1200
+ async def user_profile(username):
1201
+ return await template('profile.html',
1202
+ username=username,
1203
+ login_time="2023-01-01")
1204
+
1205
+ # In profile.html:
1206
+ # <h1>Welcome {{ username }}!</h1>
1207
+ # <p>Last login: {{ login_time }}</p>
1208
+ ```
1209
+ """
1210
+ filename = await _normalize_path(filename)
1211
+
1212
+ if not os.path.exists(filename) or not os.path.isfile(filename):
1213
+ return 404, '404 File not found'
1214
+
1215
+ with open(filename, 'r', encoding='utf-8') as f:
1216
+ template = f.read()
1217
+
1218
+ for key, value in kwargs.items():
1219
+ template = template.replace(f'{{{{ {key} }}}}', str(value))
1220
+ return template
1221
+
1222
+ async def redirect(url: str) -> tuple[int, str]:
1223
+ """
1224
+ Create a 302 redirect response to the specified URL.
1225
+
1226
+ Returns a redirect response that will cause the browser to navigate to the new URL.
1227
+ Uses meta refresh for browser compatibility.
1228
+
1229
+ Args:
1230
+ url: URL to redirect to (can be relative or absolute)
1231
+
1232
+ Returns:
1233
+ Tuple of (302, redirect_html)
1234
+
1235
+ Example:
1236
+ ```python
1237
+ @route('/old-page')
1238
+ async def old_page():
1239
+ return await redirect('/new-page')
1240
+
1241
+ @route('/login')
1242
+ async def login():
1243
+ # Redirect to external site
1244
+ return await redirect('https://example.com/auth')
1245
+ ```
1246
+ """
1247
+ html = f'<meta http-equiv="refresh" content="0;url={url}">'
1248
+ # The Location header will be added by the server logic for 3xx codes, but include it in the body for clarity
1249
+ return 302, html
1250
+
1251
+ # Websockets
1252
+ async def ws_send(sock: socket.socket, message: str): # pragma: no cover
1253
+ """
1254
+ Send a text message to a WebSocket client.
1255
+
1256
+ Formats the message as a proper WebSocket frame and sends it to the specified socket.
1257
+ Handles frame formatting including payload length encoding.
1258
+
1259
+ Args:
1260
+ sock: WebSocket client socket
1261
+ message: Text message to send
1262
+
1263
+ Example:
1264
+ ```python
1265
+ @route('/ws/chat', 'ws')
1266
+ async def chat_handler(sock):
1267
+ await ws_send(sock, "Welcome to the chat!")
1268
+
1269
+ while True:
1270
+ data = await ws_recv(sock)
1271
+ if not data:
1272
+ break
1273
+ await ws_send(sock, f"Echo: {data}")
1274
+ ```
1275
+ """
1276
+ # Send a text frame (opcode 0x1)
1277
+ loop = asyncio.get_event_loop()
1278
+ payload = message.encode('utf-8')
1279
+ frame = bytearray()
1280
+ frame.append(0x81) # FIN + text frame
1281
+ length = len(payload)
1282
+ if length < 126:
1283
+ frame.append(length)
1284
+ elif length < (1 << 16):
1285
+ frame.append(126)
1286
+ frame += length.to_bytes(2, 'big')
1287
+ else:
1288
+ frame.append(127)
1289
+ frame += length.to_bytes(8, 'big')
1290
+ frame += payload
1291
+ await loop.sock_sendall(sock, frame)
1292
+
1293
+ async def ws_recv(sock: socket.socket) -> str: # pragma: no cover
1294
+ """
1295
+ Receive a text message from a WebSocket client.
1296
+
1297
+ Reads and decodes a WebSocket frame from the client. Handles both masked (client->server)
1298
+ and unmasked frames, and supports extended payload lengths.
1299
+
1300
+ Includes built-in DOS protection that automatically disconnects clients sending too many
1301
+ messages or messages too frequently.
1302
+
1303
+ Args:
1304
+ sock: WebSocket client socket
1305
+
1306
+ Returns:
1307
+ Decoded text message, or empty string if connection closed, error occurred, or DOS protection triggered
1308
+
1309
+ Example:
1310
+ ```python
1311
+ @route('/ws/echo', 'ws')
1312
+ async def echo_handler(sock):
1313
+ while True:
1314
+ message = await ws_recv(sock)
1315
+ if not message: # Connection closed or DOS protection
1316
+ break
1317
+ await ws_send(sock, f"Echo: {message}")
1318
+ ```
1319
+ """
1320
+ # DOS Protection - check before processing message
1321
+ should_disconnect = _check_websocket_dos_protection(sock)
1322
+ if should_disconnect:
1323
+ # Clean up tracking and return empty string to signal disconnection
1324
+ _cleanup_websocket_dos_tracking(sock)
1325
+ return ''
1326
+
1327
+ # Receive a text frame (no fragmentation, no mask)
1328
+ loop = asyncio.get_event_loop()
1329
+ try:
1330
+ first2 = await loop.sock_recv(sock, 2)
1331
+ if not first2 or len(first2) < 2:
1332
+ # Connection closed - clean up DOS tracking
1333
+ _cleanup_websocket_dos_tracking(sock)
1334
+ return ''
1335
+ fin_opcode, length = first2
1336
+ if (fin_opcode & 0x0F) != 0x1:
1337
+ return '' # Only text frames
1338
+ if length & 0x80:
1339
+ # Masked (client to server)
1340
+ mask_len = length & 0x7F
1341
+ if mask_len == 126:
1342
+ ext = await loop.sock_recv(sock, 2)
1343
+ mask_len = int.from_bytes(ext, 'big')
1344
+ elif mask_len == 127:
1345
+ ext = await loop.sock_recv(sock, 8)
1346
+ mask_len = int.from_bytes(ext, 'big')
1347
+ mask = await loop.sock_recv(sock, 4)
1348
+ payload = bytearray()
1349
+ for i in range(mask_len):
1350
+ byte_data = await loop.sock_recv(sock, 1)
1351
+ if not byte_data:
1352
+ break
1353
+ payload.append(byte_data[0] ^ mask[i % 4])
1354
+ else:
1355
+ # Not masked (server to client)
1356
+ if length == 126:
1357
+ ext = await loop.sock_recv(sock, 2)
1358
+ length = int.from_bytes(ext, 'big')
1359
+ elif length == 127:
1360
+ ext = await loop.sock_recv(sock, 8)
1361
+ length = int.from_bytes(ext, 'big')
1362
+ payload = await loop.sock_recv(sock, length)
1363
+ return payload.decode('utf-8')
1364
+ except Exception:
1365
+ # Connection error - clean up DOS tracking
1366
+ _cleanup_websocket_dos_tracking(sock)
1367
+ return ''
1368
+
1369
+ async def ws_broadcast(route: str, data: str):
1370
+ """
1371
+ Broadcast a message to all connected WebSocket clients on a specific route.
1372
+
1373
+ Sends the same message to all active WebSocket connections for the specified route.
1374
+ Automatically handles disconnected clients and cleans up the connection list.
1375
+
1376
+ Args:
1377
+ route: WebSocket route path (e.g., '/ws/chat')
1378
+ data: Message to broadcast to all clients
1379
+
1380
+ Example:
1381
+ ```python
1382
+ # In a WebSocket handler
1383
+ @route('/ws/chat', 'ws')
1384
+ async def chat_handler(sock):
1385
+ username = await ws_recv(sock)
1386
+ await ws_broadcast('/ws/chat', f"{username} joined the chat")
1387
+
1388
+ while True:
1389
+ message = await ws_recv(sock)
1390
+ if not message:
1391
+ break
1392
+ # Broadcast to all connected clients
1393
+ await ws_broadcast('/ws/chat', f"{username}: {message}")
1394
+
1395
+ # From any other function
1396
+ async def notify_all():
1397
+ await ws_broadcast('/ws/notifications', "Server maintenance in 5 minutes")
1398
+ ```
1399
+ """
1400
+ if route not in ws_connections:
1401
+ log(f"No connections found for route: {route}", "DEBUG")
1402
+ return
1403
+
1404
+ # Create a copy of the connections list to avoid modification during iteration
1405
+ connections: list[socket.socket] = ws_connections[route].copy()
1406
+ disconnected: list[socket.socket] = []
1407
+
1408
+ for sock in connections:
1409
+ try:
1410
+ await ws_send(sock, data)
1411
+ except Exception as e:
1412
+ log(f"Error sending to WebSocket client: {e}", "DEBUG")
1413
+ disconnected.append(sock)
1414
+
1415
+ # Remove disconnected sockets
1416
+ for sock in disconnected:
1417
+ if route in ws_connections and sock in ws_connections[route]:
1418
+ ws_connections[route].remove(sock)
1419
+ log(f"Removed disconnected WebSocket from {route}", "DEBUG")
1420
+
1421
+ # Clean up empty route entries
1422
+ if route in ws_connections and not ws_connections[route]:
1423
+ del ws_connections[route]
1424
+ log(f"Cleaned up empty WebSocket route: {route}", "DEBUG")
1425
+
1426
+ # Password & login utils
1427
+ def _hash(password: str, iterations: int = 100_000) -> bytes:
1428
+ salt = b'\xc2\x10\x9c\xa1S?\xc4\xd1{H\x86\x9dD\xdd\x8f\xc2'
1429
+ return hashlib.pbkdf2_hmac('sha256', password.encode(), salt, iterations)
1430
+
1431
+ def _check(password: str, stored_hash: bytes, iterations: int = 100_000) -> bool:
1432
+ salt = b'\xc2\x10\x9c\xa1S?\xc4\xd1{H\x86\x9dD\xdd\x8f\xc2'
1433
+ new_hash = hashlib.pbkdf2_hmac('sha256', password.encode(), salt, iterations)
1434
+ return hmac.compare_digest(new_hash, stored_hash)
1435
+
1436
+ def _gen_token() -> str:
1437
+ """Generate a simple session token"""
1438
+ data = f"{time.time()}:{os.urandom(4)}"
1439
+ return hashlib.sha256(data.encode()).hexdigest()
1440
+
1441
+ # Login management
1442
+ def create_user(username: str, password: str) -> bool:
1443
+ """
1444
+ Create a new user with the given username and password.
1445
+ Args:
1446
+ username (str): The username for the new user account
1447
+ password (str): The plain text password for the new user account
1448
+ Returns:
1449
+ bool: True if the user was successfully created, False if the username already exists
1450
+ Note:
1451
+ The password is automatically hashed before being stored for security purposes.
1452
+ """
1453
+ if username in users:
1454
+ return False # User already exists
1455
+
1456
+ hashed_psw = _hash(password)
1457
+ users[username] = hashed_psw
1458
+ return True
1459
+
1460
+ def delete_user(username: str) -> bool:
1461
+ """
1462
+ Delete a user from the users collection.
1463
+ Args:
1464
+ username (str): The username of the user to delete.
1465
+ Returns:
1466
+ bool: True if the user was successfully deleted, False if the user does not exist.
1467
+ """
1468
+ if username not in users:
1469
+ return False # User does not exist
1470
+
1471
+ del users[username]
1472
+ return True
1473
+
1474
+ def edit_user(username: str, new_password: str) -> bool:
1475
+ """
1476
+ Edit an existing user's password.
1477
+ Args:
1478
+ username (str): The username of the user to edit.
1479
+ new_password (str): The new password to set for the user.
1480
+ Returns:
1481
+ bool: True if the user was successfully updated, False if the user does not exist.
1482
+ """
1483
+ if username not in users:
1484
+ return False # User does not exist
1485
+
1486
+ users[username] = _hash(new_password)
1487
+ return True
1488
+
1489
+ def login(username: str, password: str) -> bool:
1490
+ """
1491
+ Authenticate a user with their username and password.
1492
+ Args:
1493
+ username (str): The username to authenticate
1494
+ password (str): The password to verify against the stored user credentials
1495
+ Returns:
1496
+ bool: True if authentication is successful, False if the user doesn't exist
1497
+ or the password is incorrect
1498
+ Raises:
1499
+ None: This function handles errors by returning False rather than raising exceptions
1500
+ """
1501
+ if username not in users:
1502
+ return False # User does not exist
1503
+
1504
+ return _check(password, users[username])
1505
+
1506
+ # Profile management
1507
+ def get_user_profile(username: str) -> dict[str, Any] | None:
1508
+ """
1509
+ Get a user's profile information.
1510
+ Args:
1511
+ username (str): The username to get the profile for
1512
+ Returns:
1513
+ dict[str, Any] | None: User profile dict with 'username' and 'bio', or None if user doesn't exist
1514
+ """
1515
+ if username not in users:
1516
+ return None
1517
+
1518
+ return profiles.get(username, {'username': username, 'bio': ''})
1519
+
1520
+ def update_user_profile(username: str, bio: str) -> bool:
1521
+ """
1522
+ Update a user's profile bio.
1523
+ Args:
1524
+ username (str): The username to update
1525
+ bio (str): The new bio text
1526
+ Returns:
1527
+ bool: True if successful, False if user doesn't exist
1528
+ """
1529
+ if username not in users:
1530
+ return False
1531
+
1532
+ if username not in profiles:
1533
+ profiles[username] = {'username': username, 'bio': ''}
1534
+
1535
+ profiles[username]['bio'] = bio
1536
+ return True
1537
+
1538
+ def change_username(old_username: str, new_username: str) -> str:
1539
+ """
1540
+ Change a user's username.
1541
+ Args:
1542
+ old_username (str): Current username
1543
+ new_username (str): New username
1544
+ Returns:
1545
+ str: 'success' if changed, 'exists' if new username exists, 'failed' if old user doesn't exist
1546
+ """
1547
+ if old_username not in users:
1548
+ return 'failed'
1549
+
1550
+ if new_username in users:
1551
+ return 'exists'
1552
+
1553
+ # Move user data
1554
+ users[new_username] = users[old_username]
1555
+ del users[old_username]
1556
+
1557
+ # Move profile data if it exists
1558
+ if old_username in profiles:
1559
+ profiles[new_username] = profiles[old_username]
1560
+ profiles[new_username]['username'] = new_username
1561
+ del profiles[old_username]
1562
+
1563
+ return 'success'
1564
+
1565
+ def user_exists(username: str) -> bool:
1566
+ """
1567
+ Check if a user exists in the database.
1568
+ Args:
1569
+ username (str): The username to check
1570
+ Returns:
1571
+ bool: True if user exists, False otherwise
1572
+ """
1573
+ return username in users
1574
+
1575
+ def save_users(path:str) -> bool:
1576
+ """
1577
+ Save the current user database and profiles to a file.
1578
+
1579
+ Serializes the users dictionary and profiles dictionary to a JSON file at the specified path.
1580
+ Returns True if successful, False if an error occurs.
1581
+
1582
+ Args:
1583
+ path: File path to save the user database
1584
+
1585
+ Example:
1586
+ ```python
1587
+ save_users('users.json')
1588
+ ```
1589
+ """
1590
+ try:
1591
+ # Convert bytes to base64 strings for JSON serialization
1592
+ serializable_users = {}
1593
+ for username, hashed_password in users.items():
1594
+ if isinstance(hashed_password, bytes):
1595
+ import base64
1596
+ serializable_users[username] = base64.b64encode(hashed_password).decode('utf-8')
1597
+ else:
1598
+ serializable_users[username] = hashed_password
1599
+
1600
+ # Prepare data structure with both users and profiles
1601
+ data:dict[str, Any] = {
1602
+ 'users': serializable_users,
1603
+ 'profiles': profiles
1604
+ }
1605
+
1606
+ with open(path, 'w') as f:
1607
+ json.dump(data, f, indent=2)
1608
+ return True
1609
+ except Exception as e:
1610
+ log(f"Error saving users and profiles: {e}", "ERROR")
1611
+ return False
1612
+
1613
+ def load_users(path:str) -> bool:
1614
+ """
1615
+ Load the user database and profiles from a file.
1616
+
1617
+ Deserializes the users dictionary and profiles dictionary from a JSON file at the specified path.
1618
+ Returns True if successful, False if an error occurs.
1619
+
1620
+ Args:
1621
+ path: File path to load the user database from
1622
+
1623
+ Example:
1624
+ ```python
1625
+ load_users('users.json')
1626
+ ```
1627
+ """
1628
+ try:
1629
+ with open(path, 'r') as f:
1630
+ data:dict[str, Any] = json.load(f)
1631
+
1632
+ # New format with users and profiles
1633
+ loaded_users = data.get('users', {})
1634
+ loaded_profiles = data.get('profiles', {})
1635
+
1636
+ # Clear existing data
1637
+ users.clear()
1638
+ profiles.clear()
1639
+
1640
+ # Load users - Convert base64 strings back to bytes
1641
+ for username, hashed_password in loaded_users.items():
1642
+ if isinstance(hashed_password, str):
1643
+ try:
1644
+ # Try to decode as base64, assuming it's a hashed password
1645
+ users[username] = base64.b64decode(hashed_password.encode('utf-8'))
1646
+ except Exception:
1647
+ # If decoding fails, skip this entry or encode as bytes for backward compatibility
1648
+ log(f"Warning: Could not decode password hash for user '{username}', skipping", "WARNING")
1649
+ continue
1650
+ elif isinstance(hashed_password, bytes):
1651
+ users[username] = hashed_password
1652
+ else:
1653
+ # Skip invalid entries
1654
+ log(f"Warning: Invalid password hash type for user '{username}', skipping", "WARNING")
1655
+ continue
1656
+
1657
+ # Load profiles
1658
+ for username, profile_data in loaded_profiles.items():
1659
+ if isinstance(profile_data, dict):
1660
+ profiles[username] = profile_data
1661
+ else:
1662
+ log(f"Warning: Invalid profile data for user '{username}', skipping", "WARNING")
1663
+
1664
+ log(f"Loaded {len(users)} users, {len(profiles)} profiles", "INFO")
1665
+ return True
1666
+ except FileNotFoundError:
1667
+ log(f"User database file '{path}' not found. Starting with empty database.", "INFO")
1668
+ return True
1669
+ except Exception as e:
1670
+ log(f"Error loading users and profiles: {e}", "ERROR")
1671
+ return False
1672
+
1673
+ # Session management
1674
+ def verify_session(token: str) -> bool:
1675
+ """Verify if a session token is valid"""
1676
+ return token in sessions
1677
+
1678
+ def get_username(token: str) -> str:
1679
+ """Get username from session token"""
1680
+ return sessions.get(token, {}).get('username', '')
1681
+
1682
+ def start_session(username: str) -> str:
1683
+ """Create a new session for user"""
1684
+ token = _gen_token()
1685
+ sessions[token] = {
1686
+ 'username': username,
1687
+ 'created': time.time()
1688
+ }
1689
+ return token
1690
+
1691
+ def end_session(token: str) -> None:
1692
+ """Delete a session"""
1693
+ if token in sessions:
1694
+ del sessions[token]
1695
+
1696
+ # Cookies
1697
+ def set_cookie(name: str, value: str, path: str = '/', domain: str = '',
1698
+ expires: str = '', max_age: int = 0, secure: bool = False,
1699
+ httponly: bool = False, samesite: str = ''):
1700
+ """
1701
+ Set a cookie in the current HTTP response.
1702
+
1703
+ Sets a cookie that will be sent to the client in the response headers.
1704
+ Must be called during request handling before the response is sent.
1705
+
1706
+ Args:
1707
+ name: Cookie name
1708
+ value: Cookie value
1709
+ path: Path scope for the cookie. Defaults to '/'
1710
+ domain: Domain scope for the cookie
1711
+ expires: Expiration date string (e.g., 'Thu, 01 Jan 1970 00:00:00 GMT')
1712
+ max_age: Max age in seconds (takes precedence over expires)
1713
+ secure: If True, cookie only sent over HTTPS
1714
+ httponly: If True, cookie not accessible via JavaScript
1715
+ samesite: SameSite policy ('Strict', 'Lax', or 'None')
1716
+
1717
+ Example:
1718
+ ```python
1719
+ @route('/login', 'POST')
1720
+ async def login():
1721
+ # Set a session cookie
1722
+ set_cookie('session_id', 'abc123', httponly=True, secure=True)
1723
+
1724
+ # Set a preference cookie that expires in 30 days
1725
+ set_cookie('theme', 'dark', max_age=30*24*3600)
1726
+
1727
+ return "Logged in successfully"
1728
+ ```
1729
+ """
1730
+ resp = _get_response()
1731
+ log(f'Setting cookie: {name}={value} (max_age={max_age})', 'DEBUG') # Debug output
1732
+ resp.set_cookie(name, value, path=path, domain=domain, expires=expires,
1733
+ max_age=max_age, secure=secure, httponly=httponly, samesite=samesite)
1734
+ log(f'Cookie added to response. Total cookies: {len(resp.cookies)}', 'DEBUG') # Debug output
1735
+
1736
+ def get_cookie(name: str, default: str = '') -> str:
1737
+ """
1738
+ Get a cookie value from the current HTTP request.
1739
+
1740
+ Retrieves a cookie value that was sent by the client in the request headers.
1741
+
1742
+ Args:
1743
+ name: Cookie name to retrieve
1744
+ default: Default value if cookie not found. Defaults to empty string.
1745
+
1746
+ Returns:
1747
+ Cookie value or default if not found
1748
+
1749
+ Example:
1750
+ ```python
1751
+ @route('/dashboard')
1752
+ async def dashboard():
1753
+ session_id = get_cookie('session_id')
1754
+ theme = get_cookie('theme', 'light') # Default to 'light'
1755
+
1756
+ if not session_id:
1757
+ return await redirect('/login')
1758
+
1759
+ return f"Welcome! Theme: {theme}"
1760
+ ```
1761
+ """
1762
+ req = _get_request()
1763
+ return req.cookies.get(name, default)
1764
+
1765
+ def delete_cookie(name: str, path: str = '/'):
1766
+ """
1767
+ Delete a cookie by setting it to expire immediately.
1768
+
1769
+ Instructs the client to delete the specified cookie by setting its expiration
1770
+ date to the past. The path must match the original cookie's path.
1771
+
1772
+ Args:
1773
+ name: Cookie name to delete
1774
+ path: Path scope of the cookie to delete. Must match original path.
1775
+
1776
+ Example:
1777
+ ```python
1778
+ @route('/logout')
1779
+ async def logout():
1780
+ delete_cookie('session_id')
1781
+ delete_cookie('remember_me', path='/auth')
1782
+ return "Logged out successfully"
1783
+ ```
1784
+ """
1785
+ resp = _get_response()
1786
+ resp.delete_cookie(name, path)
1787
+
1788
+ # Decorators
1789
+ def route(path: str, method: str = 'GET') -> Callable[[RouteHandler], RouteHandler]:
1790
+ """
1791
+ Decorator to register a route handler for specific HTTP paths and methods.
1792
+
1793
+ Registers a function to handle HTTP requests to the specified path and method.
1794
+ Supports path variables in the format <variable_name>.
1795
+
1796
+ Args:
1797
+ path: URL path pattern (e.g., '/', '/api/users', '/user/<id>')
1798
+ method: HTTP method ('GET', 'POST', 'PUT', 'DELETE', 'ws' for WebSocket)
1799
+
1800
+ Returns:
1801
+ Decorator function
1802
+
1803
+ Example:
1804
+ ```python
1805
+ @route('/')
1806
+ async def home():
1807
+ return "Welcome home!"
1808
+
1809
+ @route('/api/users', 'POST')
1810
+ async def create_user():
1811
+ return "User created"
1812
+
1813
+ @route('/user/<user_id>')
1814
+ async def get_user(user_id):
1815
+ return f"User: {user_id}"
1816
+
1817
+ @route('/ws/chat', 'ws')
1818
+ async def chat_handler(sock):
1819
+ # WebSocket handler
1820
+ while True:
1821
+ data = await ws_recv(sock)
1822
+ if not data:
1823
+ break
1824
+ await ws_send(sock, f"Echo: {data}")
1825
+ ```
1826
+ """
1827
+ def decorator(func: RouteHandler):
1828
+ routes.append((path, method, func))
1829
+ return func
1830
+ return decorator
1831
+
1832
+ def error(err_code: int | None = None):
1833
+ """
1834
+ Decorator to register an error handler for specific HTTP status codes.
1835
+
1836
+ Registers a function to handle specific HTTP error codes or all errors.
1837
+ Error handlers receive the status code as a parameter if err_code is None.
1838
+
1839
+ Args:
1840
+ err_code: HTTP status code to handle (404, 500, etc.) or None for all errors
1841
+
1842
+ Returns:
1843
+ Decorator function
1844
+
1845
+ Example:
1846
+ ```python
1847
+ @error(404)
1848
+ async def not_found():
1849
+ return await template('404.html')
1850
+
1851
+ @error(500)
1852
+ async def server_error():
1853
+ return "Internal server error occurred"
1854
+
1855
+ @error() # Handle all errors
1856
+ async def handle_all_errors(status_code):
1857
+ if status_code == 403:
1858
+ return "Access denied"
1859
+ return f"Error {status_code} occurred"
1860
+ ```
1861
+ """
1862
+ def decorator(func: ErrorHandler):
1863
+ errors[err_code] = func
1864
+ return func
1865
+ return decorator
1866
+
1867
+ @overload
1868
+ def ratelimit(num_req: Union[int, float], reset_interval: Union[int, float] = -1) -> Callable[[F], F]:
1869
+ """
1870
+ Rate limiting decorator for HTTP routes and WebSocket endpoints.
1871
+
1872
+ Applies rate limiting to route handlers to prevent abuse and control request frequency.
1873
+
1874
+ Args:
1875
+ num_req: For delay-based (reset_interval=-1): delay in seconds between requests.
1876
+ For count-based (reset_interval>0): maximum number of requests allowed.
1877
+ reset_interval: Time window in seconds for count-based limiting, or -1 for delay-based.
1878
+
1879
+ Returns:
1880
+ Decorated function with rate limiting applied
1881
+ """
1882
+ ...
1883
+
1884
+ @overload
1885
+ def ratelimit(sock: socket.socket, delay_seconds: Union[int, float], identifier: str = "") -> bool:
1886
+ """
1887
+ Check rate limiting for WebSocket data transmission (delay-based).
1888
+
1889
+ Performs delay-based rate limiting check for individual WebSocket messages.
1890
+ This is a simplified version where only delay between messages is specified.
1891
+
1892
+ Args:
1893
+ sock: WebSocket client socket
1894
+ delay_seconds: Minimum delay in seconds between messages (assumes reset_interval = -1)
1895
+ identifier: Optional identifier for different rate limit categories
1896
+
1897
+ Returns:
1898
+ True if rate limited (should block), False if allowed (can proceed)
1899
+ """
1900
+ ...
1901
+
1902
+ @overload
1903
+ def ratelimit(sock: socket.socket, rate_params: tuple[Union[int, float], Union[int, float]], identifier: str = "") -> bool:
1904
+ """
1905
+ Check rate limiting for WebSocket data transmission (full control).
1906
+
1907
+ Performs rate limiting check for individual WebSocket messages or data transmissions.
1908
+ Supports both delay-based and count-based rate limiting.
1909
+
1910
+ Args:
1911
+ sock: WebSocket client socket
1912
+ rate_params: Tuple of (num_req, reset_interval) where:
1913
+ - For delay-based: (delay_seconds, -1)
1914
+ - For count-based: (max_requests, time_window)
1915
+ identifier: Optional identifier for different rate limit categories
1916
+
1917
+ Returns:
1918
+ True if rate limited (should block), False if allowed (can proceed)
1919
+ """
1920
+ ...
1921
+
1922
+ def ratelimit(*args: Any, **kwargs: Any) -> Union[Callable[[F], F], bool]:
1923
+ """Docstrings are in the overloads, dont add one here!"""
1924
+ # Parse arguments based on first argument type
1925
+ if args and isinstance(args[0], socket.socket):
1926
+ # WebSocket function mode: ratelimit(sock, (num_req, reset_interval), identifier="")
1927
+ sock = args[0]
1928
+ rate_params = args[1] if len(args) > 1 else (1.0, -1)
1929
+ identifier = str(args[2]) if len(args) > 2 else str(kwargs.get('identifier', ''))
1930
+
1931
+ # Extract rate limiting parameters with safe type handling
1932
+ num_req: float = 1.0
1933
+ reset_interval: float = -1.0
1934
+
1935
+ try:
1936
+ if hasattr(rate_params, '__len__') and hasattr(rate_params, '__getitem__'):
1937
+ # It's a sequence-like object
1938
+ if len(rate_params) >= 2: # type: ignore
1939
+ num_req = float(rate_params[0]) # type: ignore
1940
+ reset_interval = float(rate_params[1]) # type: ignore
1941
+ elif len(rate_params) >= 1: # type: ignore
1942
+ num_req = float(rate_params[0]) # type: ignore
1943
+ reset_interval = -1.0
1944
+ else:
1945
+ # Handle single numeric value as delay-based
1946
+ num_req = float(rate_params) # type: ignore
1947
+ reset_interval = -1.0
1948
+ except (ValueError, TypeError, IndexError):
1949
+ # Fallback to defaults if anything goes wrong
1950
+ num_req = 1.0
1951
+ reset_interval = -1.0 # Get client IP from socket
1952
+ try:
1953
+ client_addr = sock.getpeername()
1954
+ client_ip = client_addr[0] if client_addr else 'unknown'
1955
+ except Exception:
1956
+ client_ip = 'unknown'
1957
+
1958
+ # DOS Protection - check if IP is blocked
1959
+ if is_ip_blocked(client_ip):
1960
+ log(f"WebSocket data blocked: IP {client_ip} is blocked", "WARNING")
1961
+ return True # Block the data transmission
1962
+
1963
+ # Use socket object as part of the key for WebSocket-specific rate limiting
1964
+ rate_key = (sock, identifier or 'ws_data')
1965
+ current_time = time.time()
1966
+
1967
+ with rate_limit_lock:
1968
+ if reset_interval == -1:
1969
+ # Delay-based rate limiting for WebSocket data
1970
+ if rate_key in ws_rate_limit_delays:
1971
+ time_since_last = current_time - ws_rate_limit_delays[rate_key]
1972
+ if time_since_last < num_req:
1973
+ # Rate limited - reset the delay
1974
+ ws_rate_limit_delays[rate_key] = current_time
1975
+ remaining_delay = num_req - time_since_last
1976
+ log(f"WebSocket rate limited {client_ip} on {identifier or 'data'}, delay reset. Wait {remaining_delay:.1f}s", "WARNING")
1977
+ return True # Rate limited
1978
+
1979
+ # Update last request time
1980
+ ws_rate_limit_delays[rate_key] = current_time
1981
+ return False # Not rate limited
1982
+ else:
1983
+ # Count-based rate limiting for WebSocket data
1984
+ if rate_key not in ws_rate_limit_counts:
1985
+ ws_rate_limit_counts[rate_key] = []
1986
+
1987
+ # Clean old requests outside the time window
1988
+ ws_rate_limit_counts[rate_key] = [
1989
+ req_time for req_time in ws_rate_limit_counts[rate_key]
1990
+ if current_time - req_time < reset_interval
1991
+ ]
1992
+
1993
+ # Check if rate limit exceeded
1994
+ if len(ws_rate_limit_counts[rate_key]) >= num_req:
1995
+ log(f"WebSocket rate limited {client_ip} on {identifier or 'data'}, {len(ws_rate_limit_counts[rate_key])} requests in {reset_interval}s", "WARNING")
1996
+ return True # Rate limited
1997
+
1998
+ # Add current request
1999
+ ws_rate_limit_counts[rate_key].append(current_time)
2000
+ return False # Not rate limited
2001
+ else:
2002
+ # Decorator mode: ratelimit(num_req, reset_interval=-1)
2003
+ num_req = float(args[0]) if args else 1.0
2004
+ reset_interval = float(args[1]) if len(args) > 1 else -1.0
2005
+
2006
+ def decorator(func: F) -> F:
2007
+ @wraps(func)
2008
+ async def wrapper(*args: Any, **kwargs: Any) -> Any:
2009
+ # Get client IP - try to extract from various sources
2010
+ client_ip = get_client_ip()
2011
+
2012
+ # DOS Protection - check if IP is blocked (additional layer)
2013
+ if is_ip_blocked(client_ip):
2014
+ if 'sock' in str(func.__annotations__.values()): # WebSocket
2015
+ return
2016
+ else:
2017
+ return 429, "IP temporarily blocked due to suspicious activity"
2018
+
2019
+ # Get route path from request context or function name
2020
+ route_path = getattr(_get_request(), 'path', func.__name__)
2021
+
2022
+ rate_key = (client_ip, route_path)
2023
+ current_time = time.time()
2024
+
2025
+ with rate_limit_lock:
2026
+ if reset_interval == -1:
2027
+ # Delay-based rate limiting
2028
+ if rate_key in rate_limit_delays:
2029
+ time_since_last = current_time - rate_limit_delays[rate_key]
2030
+ if time_since_last < num_req:
2031
+ # Rate limited - reset the delay
2032
+ rate_limit_delays[rate_key] = current_time
2033
+ remaining_delay = num_req - time_since_last
2034
+ log(f"Rate limited {client_ip} on {route_path}, delay reset. Wait {remaining_delay:.1f}s", "WARNING")
2035
+
2036
+ # Return rate limit response
2037
+ if 'sock' in str(func.__annotations__.values()): # WebSocket
2038
+ # For WebSocket, we can't return an HTTP response, so just close
2039
+ return
2040
+ else:
2041
+ return 429, f"Rate limited. Try again in {remaining_delay:.1f} seconds."
2042
+
2043
+ # Update last request time
2044
+ rate_limit_delays[rate_key] = current_time
2045
+
2046
+ else:
2047
+ # Count-based rate limiting
2048
+ if rate_key not in rate_limit_counts:
2049
+ rate_limit_counts[rate_key] = []
2050
+
2051
+ # Clean old requests outside the time window
2052
+ rate_limit_counts[rate_key] = [
2053
+ req_time for req_time in rate_limit_counts[rate_key]
2054
+ if current_time - req_time < reset_interval
2055
+ ]
2056
+
2057
+ # Check if rate limit exceeded
2058
+ if len(rate_limit_counts[rate_key]) >= num_req:
2059
+ log(f"Rate limited {client_ip} on {route_path}, {len(rate_limit_counts[rate_key])} requests in {reset_interval}s", "WARNING")
2060
+
2061
+ if 'sock' in str(func.__annotations__.values()): # WebSocket
2062
+ return
2063
+ else:
2064
+ return 429, f"Rate limited. Max {num_req} requests per {reset_interval} seconds."
2065
+
2066
+ # Add current request
2067
+ rate_limit_counts[rate_key].append(current_time)
2068
+
2069
+ # Call the original function
2070
+ return await func(*args, **kwargs)
2071
+
2072
+ return wrapper # type: ignore
2073
+
2074
+ return decorator
2075
+
2076
+ __all__ = [
2077
+ 'serve', 'route', 'error', 'ratelimit',
2078
+ 'template', 'redirect',
2079
+ 'log',
2080
+ 'ws_send', 'ws_recv', 'ws_broadcast',
2081
+ 'request', 'response',
2082
+ 'set_cookie', 'get_cookie', 'delete_cookie',
2083
+ # DOS protection management
2084
+ 'is_ip_blocked', 'check_dos_protection', 'count_websocket_connections',
2085
+ 'unblock_ip', 'get_blocked_ips', 'get_dos_stats',
2086
+ # DOS protection configuration
2087
+ 'DOS_WINDOW_SIZE', 'DOS_MAX_REQUESTS', 'DOS_BLOCK_DURATION', 'DOS_WEBSOCKET_MAX_CONNECTIONS',
2088
+ 'DOS_WS_MAX_MESSAGES_PER_SECOND', 'DOS_WS_MIN_MESSAGE_INTERVAL', 'DOS_WS_MAX_VIOLATIONS'
2089
+ ]