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
|
+
]
|