limbo33 0.9.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.
- limbo/__init__.py +33 -0
- limbo/app.py +565 -0
- limbo/auth.py +42 -0
- limbo/build_engine.py +488 -0
- limbo/capabilities.py +48 -0
- limbo/cli.py +308 -0
- limbo/config.py +130 -0
- limbo/csrf.py +87 -0
- limbo/css_scanner.py +38 -0
- limbo/database.py +58 -0
- limbo/frontend.py +1059 -0
- limbo/frontend_cache.py +87 -0
- limbo/frontend_engine.py +270 -0
- limbo/frontend_scanner.py +448 -0
- limbo/http.py +281 -0
- limbo/middleware.py +250 -0
- limbo/migrations.py +239 -0
- limbo/project.py +57 -0
- limbo/router.py +177 -0
- limbo/runtime_ssr.py +109 -0
- limbo/security.py +94 -0
- limbo/server.py +559 -0
- limbo/sessions.py +184 -0
- limbo/sfc_bridge.cjs +91 -0
- limbo/tasks.py +99 -0
- limbo/templates.py +27 -0
- limbo/tunnel.py +180 -0
- limbo/uploads.py +318 -0
- limbo/websocket.py +126 -0
- limbo33-0.9.0.dist-info/METADATA +148 -0
- limbo33-0.9.0.dist-info/RECORD +35 -0
- limbo33-0.9.0.dist-info/WHEEL +5 -0
- limbo33-0.9.0.dist-info/entry_points.txt +2 -0
- limbo33-0.9.0.dist-info/licenses/LICENSE +21 -0
- limbo33-0.9.0.dist-info/top_level.txt +1 -0
limbo/__init__.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Limbo: a zero-dependency Python web framework foundation."""
|
|
2
|
+
from .app import Limbo
|
|
3
|
+
from .router import RouteGroup, Router
|
|
4
|
+
from .http import HTTPError, Request, Response
|
|
5
|
+
from .database import Database
|
|
6
|
+
from .frontend import DependencyGraph, FrontendCompiler, FrontendDevServer, FrontendError, ModuleResolver
|
|
7
|
+
from .frontend_engine import (AdapterCapability, AdapterError, AdapterRegistry, BrowserNativeAdapter,
|
|
8
|
+
CompilerAdapter, EsbuildAdapter, FrameworkSFCAdapter, TransformResult)
|
|
9
|
+
from .security import SessionSigner, hash_password, session_cookie, verify_password
|
|
10
|
+
from .sessions import Session, SessionBackend, SessionStore, session_middleware
|
|
11
|
+
from .csrf import csrf_protect
|
|
12
|
+
from .tasks import BackgroundTasks
|
|
13
|
+
from .auth import authenticate, authorize
|
|
14
|
+
from .templates import TemplateEngine
|
|
15
|
+
from .uploads import UploadFile, UploadLimitError
|
|
16
|
+
from .middleware import access_log, cors, gzip_compress, rate_limit, request_id, security_headers
|
|
17
|
+
|
|
18
|
+
from .migrations import DatabaseURLError, Migration, MigrationError, Migrator, load_directory, open_database
|
|
19
|
+
from .build_engine import BuildError, BuildReport, build_project
|
|
20
|
+
from .tunnel import TunnelError, TunnelSupervisor, detect_cloudflared, redact_secrets
|
|
21
|
+
from .runtime_ssr import LocalProcessRuntime, SsrRuntimeError
|
|
22
|
+
from .capabilities import capability_matrix
|
|
23
|
+
|
|
24
|
+
__version__ = "0.9.0"
|
|
25
|
+
__all__ = ["Limbo", "Router", "RouteGroup", "Request", "Response", "HTTPError", "Database", "SessionSigner",
|
|
26
|
+
"Session", "SessionBackend", "SessionStore", "session_middleware", "csrf_protect", "BackgroundTasks", "authenticate", "authorize", "TemplateEngine", "UploadFile", "UploadLimitError", "DependencyGraph",
|
|
27
|
+
"FrontendCompiler", "FrontendDevServer", "FrontendError", "ModuleResolver", "hash_password",
|
|
28
|
+
"verify_password", "session_cookie", "cors", "gzip_compress", "security_headers", "request_id",
|
|
29
|
+
"access_log", "rate_limit", "AdapterCapability", "AdapterError", "AdapterRegistry",
|
|
30
|
+
"BrowserNativeAdapter", "CompilerAdapter", "EsbuildAdapter", "FrameworkSFCAdapter", "TransformResult",
|
|
31
|
+
"Migration", "Migrator", "MigrationError", "DatabaseURLError", "load_directory", "open_database",
|
|
32
|
+
"BuildError", "BuildReport", "build_project", "TunnelError", "TunnelSupervisor", "detect_cloudflared",
|
|
33
|
+
"redact_secrets", "LocalProcessRuntime", "SsrRuntimeError", "capability_matrix", "__version__"]
|
limbo/app.py
ADDED
|
@@ -0,0 +1,565 @@
|
|
|
1
|
+
"""Public Limbo application API."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import hashlib
|
|
5
|
+
import mimetypes
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Callable
|
|
9
|
+
from urllib.parse import unquote, urlsplit
|
|
10
|
+
|
|
11
|
+
from .http import HTTPError, Request, Response
|
|
12
|
+
from .frontend import FrontendDevServer
|
|
13
|
+
from .router import RouteGroup, Router, compile_path
|
|
14
|
+
from .server import LimboServer
|
|
15
|
+
from .templates import TemplateEngine
|
|
16
|
+
from .tasks import BackgroundTasks
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class _FileDescriptorStream:
|
|
20
|
+
"""Close an eagerly opened file descriptor even if iteration never starts."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, descriptor: int, length: int, chunk_size: int = 64 * 1024) -> None:
|
|
23
|
+
self.descriptor = descriptor
|
|
24
|
+
self.remaining = length
|
|
25
|
+
self.chunk_size = chunk_size
|
|
26
|
+
|
|
27
|
+
def __iter__(self):
|
|
28
|
+
return self
|
|
29
|
+
|
|
30
|
+
def __next__(self) -> bytes:
|
|
31
|
+
if self.descriptor is None or self.remaining <= 0:
|
|
32
|
+
self.close()
|
|
33
|
+
raise StopIteration
|
|
34
|
+
try:
|
|
35
|
+
chunk = os.read(self.descriptor, min(self.chunk_size, self.remaining))
|
|
36
|
+
except Exception:
|
|
37
|
+
self.close()
|
|
38
|
+
raise
|
|
39
|
+
if not chunk:
|
|
40
|
+
self.close()
|
|
41
|
+
raise StopIteration
|
|
42
|
+
self.remaining -= len(chunk)
|
|
43
|
+
return chunk
|
|
44
|
+
|
|
45
|
+
def close(self) -> None:
|
|
46
|
+
descriptor = self.descriptor
|
|
47
|
+
self.descriptor = None
|
|
48
|
+
if descriptor is not None:
|
|
49
|
+
try:
|
|
50
|
+
os.close(descriptor)
|
|
51
|
+
except OSError:
|
|
52
|
+
pass
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class Limbo:
|
|
56
|
+
def __init__(self, *, name: str = "Limbo", max_body_bytes: int = 8 * 1024 * 1024,
|
|
57
|
+
max_upload_file_bytes: int = 8 * 1024 * 1024,
|
|
58
|
+
max_upload_files: int = 20, max_form_fields: int = 100) -> None:
|
|
59
|
+
if not isinstance(max_body_bytes, int) or max_body_bytes < 0:
|
|
60
|
+
raise ValueError("max_body_bytes must be a non-negative integer")
|
|
61
|
+
if not isinstance(max_upload_file_bytes, int) or max_upload_file_bytes < 1:
|
|
62
|
+
raise ValueError("max_upload_file_bytes must be positive")
|
|
63
|
+
if not isinstance(max_upload_files, int) or max_upload_files < 1:
|
|
64
|
+
raise ValueError("max_upload_files must be positive")
|
|
65
|
+
if not isinstance(max_form_fields, int) or max_form_fields < 1:
|
|
66
|
+
raise ValueError("max_form_fields must be positive")
|
|
67
|
+
self.name = name
|
|
68
|
+
self.router = Router()
|
|
69
|
+
self.middlewares: list[Callable] = []
|
|
70
|
+
self.static_mounts: list[tuple[str, Path, str | None, str, str, str | None]] = []
|
|
71
|
+
self.websocket_routes: list[tuple[object, Callable, tuple[str, ...] | None, int, float | None]] = []
|
|
72
|
+
self.templates = TemplateEngine()
|
|
73
|
+
self.frontend_servers: list[FrontendDevServer] = []
|
|
74
|
+
self.max_body_bytes = max_body_bytes
|
|
75
|
+
self.upload_limits = {"max_body_bytes": max_body_bytes, "max_file_bytes": max_upload_file_bytes,
|
|
76
|
+
"max_files": max_upload_files, "max_fields": max_form_fields}
|
|
77
|
+
self.exception_handlers: dict[object, Callable] = {}
|
|
78
|
+
self.background_tasks = BackgroundTasks()
|
|
79
|
+
self.startup_hooks: list[Callable] = []
|
|
80
|
+
self.shutdown_hooks: list[Callable] = []
|
|
81
|
+
self._server: LimboServer | None = None
|
|
82
|
+
|
|
83
|
+
def on_startup(self, function: Callable | None = None):
|
|
84
|
+
"""Register a synchronous hook called immediately before serving."""
|
|
85
|
+
def register(fn):
|
|
86
|
+
if not callable(fn):
|
|
87
|
+
raise TypeError("Startup hook must be callable")
|
|
88
|
+
self.startup_hooks.append(fn)
|
|
89
|
+
return fn
|
|
90
|
+
return register(function) if function else register
|
|
91
|
+
|
|
92
|
+
def on_shutdown(self, function: Callable | None = None):
|
|
93
|
+
"""Register a synchronous hook called in reverse order during shutdown."""
|
|
94
|
+
def register(fn):
|
|
95
|
+
if not callable(fn):
|
|
96
|
+
raise TypeError("Shutdown hook must be callable")
|
|
97
|
+
self.shutdown_hooks.append(fn)
|
|
98
|
+
return fn
|
|
99
|
+
return register(function) if function else register
|
|
100
|
+
|
|
101
|
+
def shutdown(self) -> None:
|
|
102
|
+
"""Request server shutdown; run() performs orderly hook/task cleanup."""
|
|
103
|
+
if self._server is not None:
|
|
104
|
+
self._server.shutdown()
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def routes(self) -> list[dict[str, object]]:
|
|
108
|
+
"""Return a snapshot of registered HTTP route metadata."""
|
|
109
|
+
return self.router.describe()
|
|
110
|
+
|
|
111
|
+
def url_for(self, name: str, **parameters: object) -> str:
|
|
112
|
+
"""Build a URL from a named route and its path parameters."""
|
|
113
|
+
return self.router.url_for(name, **parameters)
|
|
114
|
+
|
|
115
|
+
def group(self, prefix: str = "", *, name: str | None = None) -> RouteGroup:
|
|
116
|
+
"""Create a route group that shares this application's router."""
|
|
117
|
+
return RouteGroup(self.router, prefix=prefix, name=name)
|
|
118
|
+
|
|
119
|
+
def route(self, path: str, methods: tuple[str, ...] = ("GET",), *,
|
|
120
|
+
name: str | None = None, priority: int = 0, middleware: tuple[Callable, ...] = ()):
|
|
121
|
+
def decorate(handler):
|
|
122
|
+
for method in methods:
|
|
123
|
+
self.router.add(method, path, handler, name=name, priority=priority, middleware=middleware)
|
|
124
|
+
return handler
|
|
125
|
+
return decorate
|
|
126
|
+
|
|
127
|
+
def get(self, path: str, **kwargs): return self.route(path, ("GET",), **kwargs)
|
|
128
|
+
def post(self, path: str, **kwargs): return self.route(path, ("POST",), **kwargs)
|
|
129
|
+
def put(self, path: str, **kwargs): return self.route(path, ("PUT",), **kwargs)
|
|
130
|
+
def patch(self, path: str, **kwargs): return self.route(path, ("PATCH",), **kwargs)
|
|
131
|
+
def delete(self, path: str, **kwargs): return self.route(path, ("DELETE",), **kwargs)
|
|
132
|
+
|
|
133
|
+
def websocket(self, path: str, *, origins: tuple[str, ...] | list[str] | None = None,
|
|
134
|
+
max_message_bytes: int = 8 * 1024 * 1024,
|
|
135
|
+
idle_timeout: float | None = 60.0):
|
|
136
|
+
"""Register a WebSocket route with explicit Origin, message and idle policies."""
|
|
137
|
+
if not isinstance(max_message_bytes, int) or max_message_bytes < 1:
|
|
138
|
+
raise ValueError("max_message_bytes must be a positive integer")
|
|
139
|
+
if idle_timeout is not None and (not isinstance(idle_timeout, (int, float)) or idle_timeout <= 0):
|
|
140
|
+
raise ValueError("idle_timeout must be positive or None")
|
|
141
|
+
allowed_origins = None if origins is None else tuple(origins)
|
|
142
|
+
if allowed_origins is not None:
|
|
143
|
+
if not allowed_origins:
|
|
144
|
+
raise ValueError("WebSocket Origin allowlist cannot be empty")
|
|
145
|
+
for origin in allowed_origins:
|
|
146
|
+
try:
|
|
147
|
+
parsed = urlsplit(origin)
|
|
148
|
+
valid = (parsed.scheme in {"http", "https"} and bool(parsed.netloc)
|
|
149
|
+
and parsed.hostname is not None and parsed.username is None
|
|
150
|
+
and parsed.password is None and not parsed.path
|
|
151
|
+
and not parsed.query and not parsed.fragment
|
|
152
|
+
and (parsed.port is None or 1 <= parsed.port <= 65535))
|
|
153
|
+
except (TypeError, ValueError):
|
|
154
|
+
valid = False
|
|
155
|
+
if not valid:
|
|
156
|
+
raise ValueError("WebSocket origins must be exact HTTP(S) origins")
|
|
157
|
+
def decorate(handler):
|
|
158
|
+
pattern = compile_path(path)
|
|
159
|
+
if any(existing.pattern == pattern.pattern for existing, _, _, _, _ in self.websocket_routes):
|
|
160
|
+
raise ValueError(f"Duplicate WebSocket route: {path}")
|
|
161
|
+
self.websocket_routes.append((pattern, handler, allowed_origins, max_message_bytes, idle_timeout))
|
|
162
|
+
return handler
|
|
163
|
+
return decorate
|
|
164
|
+
|
|
165
|
+
def websocket_origin_allowed(self, request: Request) -> bool:
|
|
166
|
+
"""Check a matching WebSocket route's configured Origin policy."""
|
|
167
|
+
for pattern, _, origins, _, _ in self.websocket_routes:
|
|
168
|
+
if pattern.fullmatch(request.path):
|
|
169
|
+
return origins is None or request.headers.get("origin") in origins
|
|
170
|
+
return True
|
|
171
|
+
|
|
172
|
+
def websocket_options(self, request: Request) -> dict[str, object]:
|
|
173
|
+
"""Return configured transport limits for the matching route."""
|
|
174
|
+
for pattern, _, _, max_message_bytes, idle_timeout in self.websocket_routes:
|
|
175
|
+
if pattern.fullmatch(request.path):
|
|
176
|
+
return {"max_message_bytes": max_message_bytes, "idle_timeout": idle_timeout}
|
|
177
|
+
return {}
|
|
178
|
+
|
|
179
|
+
def render_template(self, template_name: str, **context) -> Response:
|
|
180
|
+
return self.html(self.templates.render(template_name, context))
|
|
181
|
+
|
|
182
|
+
def template_directory(self, directory: str | os.PathLike[str]) -> None:
|
|
183
|
+
self.templates = TemplateEngine(directory)
|
|
184
|
+
|
|
185
|
+
def enable_frontend(self, directory: str | os.PathLike[str], *, url_prefix: str = "/__limbo") -> FrontendDevServer:
|
|
186
|
+
frontend = FrontendDevServer(directory, url_prefix=url_prefix)
|
|
187
|
+
if any(existing.prefix == frontend.prefix for existing in self.frontend_servers):
|
|
188
|
+
raise ValueError(f"Frontend URL prefix already mounted: {frontend.prefix}")
|
|
189
|
+
self.frontend_servers.append(frontend)
|
|
190
|
+
return frontend
|
|
191
|
+
|
|
192
|
+
def frontend_script(self, entry: str, *, mount: int = 0) -> str:
|
|
193
|
+
try:
|
|
194
|
+
frontend = self.frontend_servers[mount]
|
|
195
|
+
except IndexError as exc:
|
|
196
|
+
raise ValueError("Enable a frontend directory before requesting its script tag") from exc
|
|
197
|
+
return frontend.script_tag(entry)
|
|
198
|
+
|
|
199
|
+
def dispatch_websocket(self, request: Request, websocket) -> None:
|
|
200
|
+
for pattern, handler, _, _, _ in self.websocket_routes:
|
|
201
|
+
match = pattern.fullmatch(request.path)
|
|
202
|
+
if match:
|
|
203
|
+
request.path_params = match.groupdict()
|
|
204
|
+
handler(request, websocket)
|
|
205
|
+
return
|
|
206
|
+
websocket.close(1008, "Route not found")
|
|
207
|
+
|
|
208
|
+
def exception_handler(self, exception_or_status):
|
|
209
|
+
"""Register an error handler for an HTTP status or exception class."""
|
|
210
|
+
if not (isinstance(exception_or_status, int) and 400 <= exception_or_status <= 599) and not (
|
|
211
|
+
isinstance(exception_or_status, type) and issubclass(exception_or_status, Exception)
|
|
212
|
+
):
|
|
213
|
+
raise TypeError("Use an HTTP error status or Exception subclass")
|
|
214
|
+
def register(handler):
|
|
215
|
+
self.exception_handlers[exception_or_status] = handler
|
|
216
|
+
return handler
|
|
217
|
+
return register
|
|
218
|
+
|
|
219
|
+
def _error_response(self, error: Exception, status: int) -> Response:
|
|
220
|
+
handler = self.exception_handlers.get(status)
|
|
221
|
+
if handler is None:
|
|
222
|
+
for cls in type(error).__mro__:
|
|
223
|
+
if cls in self.exception_handlers:
|
|
224
|
+
handler = self.exception_handlers[cls]
|
|
225
|
+
break
|
|
226
|
+
if handler is None:
|
|
227
|
+
return Response.text(str(error) if isinstance(error, HTTPError) else "Internal Server Error", status)
|
|
228
|
+
try:
|
|
229
|
+
return self._coerce(handler(error))
|
|
230
|
+
except Exception:
|
|
231
|
+
return Response.text("Internal Server Error", 500)
|
|
232
|
+
def middleware(self, function: Callable | None = None):
|
|
233
|
+
def register(fn):
|
|
234
|
+
self.middlewares.append(fn)
|
|
235
|
+
return fn
|
|
236
|
+
return register(function) if function else register
|
|
237
|
+
|
|
238
|
+
def static(self, url_prefix: str, directory: str | os.PathLike[str], *,
|
|
239
|
+
index: str | None = None, cache_control: str = "public, max-age=0, must-revalidate",
|
|
240
|
+
symlinks: str = "deny", spa_fallback: str | None = None) -> None:
|
|
241
|
+
"""Mount static files with optional safe SPA navigation fallback."""
|
|
242
|
+
prefix = "/" + url_prefix.strip("/")
|
|
243
|
+
if prefix == "/":
|
|
244
|
+
raise ValueError("Static URL prefix cannot be root")
|
|
245
|
+
if symlinks not in {"deny", "within-root"}:
|
|
246
|
+
raise ValueError("symlinks must be 'deny' or 'within-root'")
|
|
247
|
+
if index is not None and (not index or "/" in index or "\\" in index or index in {".", ".."}):
|
|
248
|
+
raise ValueError("Static index must be a single safe filename")
|
|
249
|
+
if spa_fallback is not None:
|
|
250
|
+
fallback_parts = spa_fallback.split("/")
|
|
251
|
+
if not spa_fallback or spa_fallback.startswith("/") or "\\" in spa_fallback or any(part in {"", ".", ".."} for part in fallback_parts):
|
|
252
|
+
raise ValueError("SPA fallback must be a safe relative path")
|
|
253
|
+
if any(existing[0] == prefix for existing in self.static_mounts):
|
|
254
|
+
raise ValueError(f"Static URL prefix already mounted: {prefix}")
|
|
255
|
+
root = Path(directory).expanduser().resolve(strict=True)
|
|
256
|
+
if not root.is_dir():
|
|
257
|
+
raise ValueError("Static directory must be a directory")
|
|
258
|
+
self.static_mounts.append((prefix, root, index, cache_control, symlinks, spa_fallback))
|
|
259
|
+
|
|
260
|
+
@staticmethod
|
|
261
|
+
def html(value: str, status: int = 200, headers: dict[str, str] | None = None) -> Response:
|
|
262
|
+
result = {"Content-Type": "text/html; charset=utf-8"}; result.update(headers or {})
|
|
263
|
+
return Response(value, status, result)
|
|
264
|
+
|
|
265
|
+
@staticmethod
|
|
266
|
+
def json(value, status: int = 200, headers: dict[str, str] | None = None) -> Response:
|
|
267
|
+
return Response.json(value, status, headers)
|
|
268
|
+
|
|
269
|
+
@staticmethod
|
|
270
|
+
def redirect(location: str, status: int = 303) -> Response:
|
|
271
|
+
return Response.redirect(location, status)
|
|
272
|
+
|
|
273
|
+
@staticmethod
|
|
274
|
+
def send_file(path: str | os.PathLike[str], *, download_name: str | None = None) -> Response:
|
|
275
|
+
file_path = Path(path).resolve(strict=True)
|
|
276
|
+
if not file_path.is_file():
|
|
277
|
+
raise FileNotFoundError(path)
|
|
278
|
+
size = file_path.stat().st_size
|
|
279
|
+
def chunks():
|
|
280
|
+
with file_path.open("rb") as file:
|
|
281
|
+
while True:
|
|
282
|
+
chunk = file.read(64 * 1024)
|
|
283
|
+
if not chunk: break
|
|
284
|
+
yield chunk
|
|
285
|
+
content_type = mimetypes.guess_type(file_path.name)[0] or "application/octet-stream"
|
|
286
|
+
headers = {"Content-Type": content_type, "Content-Length": str(size)}
|
|
287
|
+
if download_name:
|
|
288
|
+
safe = download_name.replace('"', "")
|
|
289
|
+
headers["Content-Disposition"] = f'attachment; filename="{safe}"'
|
|
290
|
+
return Response(chunks(), 200, headers)
|
|
291
|
+
|
|
292
|
+
def dispatch(self, request: Request) -> Response:
|
|
293
|
+
if len(request.body) > self.max_body_bytes:
|
|
294
|
+
request.close_uploads()
|
|
295
|
+
return Response.text("Request body too large", 413)
|
|
296
|
+
request._multipart_limits = self.upload_limits
|
|
297
|
+
try:
|
|
298
|
+
def endpoint(req: Request) -> Response:
|
|
299
|
+
if req.method == "OPTIONS" and req.path == "*":
|
|
300
|
+
methods = {route.method for route in self.router.routes}
|
|
301
|
+
methods.add("OPTIONS")
|
|
302
|
+
return Response(b"", 204, {"Allow": ", ".join(sorted(methods))})
|
|
303
|
+
route, params, allowed = self.router.match(req.method, req.path)
|
|
304
|
+
if route:
|
|
305
|
+
req.path_params = params
|
|
306
|
+
endpoint = lambda current: self._coerce(route.handler(current))
|
|
307
|
+
for middleware in reversed(route.middleware):
|
|
308
|
+
downstream = endpoint
|
|
309
|
+
endpoint = lambda current, mw=middleware, nxt=downstream: self._coerce(mw(current, nxt))
|
|
310
|
+
return endpoint(req)
|
|
311
|
+
if req.method == "OPTIONS" and allowed:
|
|
312
|
+
return Response(b"", 204, {"Allow": ", ".join(sorted(allowed | {"OPTIONS"}))})
|
|
313
|
+
for prefix, root, index, cache_control, symlinks, spa_fallback in self.static_mounts:
|
|
314
|
+
if req.path == prefix or req.path.startswith(prefix + "/"):
|
|
315
|
+
return self._serve_static(req, prefix, root, index, cache_control, symlinks, spa_fallback)
|
|
316
|
+
for frontend in self.frontend_servers:
|
|
317
|
+
if req.path == frontend.prefix or req.path.startswith(frontend.prefix + "/"):
|
|
318
|
+
return frontend.serve(req)
|
|
319
|
+
if allowed:
|
|
320
|
+
response = self._error_response(HTTPError(405, "Method Not Allowed"), 405)
|
|
321
|
+
response.headers.setdefault("Allow", ", ".join(sorted(allowed | {"OPTIONS"})))
|
|
322
|
+
return response
|
|
323
|
+
return self._error_response(HTTPError(404, "Not Found"), 404)
|
|
324
|
+
|
|
325
|
+
next_handler = endpoint
|
|
326
|
+
for middleware in reversed(self.middlewares):
|
|
327
|
+
downstream = next_handler
|
|
328
|
+
next_handler = lambda req, mw=middleware, nxt=downstream: self._coerce(mw(req, nxt))
|
|
329
|
+
return next_handler(request)
|
|
330
|
+
except HTTPError as exc:
|
|
331
|
+
return self._error_response(exc, exc.status)
|
|
332
|
+
except Exception as exc:
|
|
333
|
+
return self._error_response(exc, 500)
|
|
334
|
+
finally:
|
|
335
|
+
request.close_uploads()
|
|
336
|
+
|
|
337
|
+
@staticmethod
|
|
338
|
+
def _coerce(value) -> Response:
|
|
339
|
+
if isinstance(value, Response): return value
|
|
340
|
+
if isinstance(value, dict) or isinstance(value, list): return Response.json(value)
|
|
341
|
+
if isinstance(value, bytes): return Response(value, 200, {"Content-Type": "application/octet-stream"})
|
|
342
|
+
if isinstance(value, str): return Response.text(value)
|
|
343
|
+
if value is None: return Response(b"", 204)
|
|
344
|
+
raise TypeError(f"Unsupported route return type: {type(value).__name__}")
|
|
345
|
+
|
|
346
|
+
@staticmethod
|
|
347
|
+
def _etag_matches(header: str, etag: str) -> bool:
|
|
348
|
+
target = etag.removeprefix("W/")
|
|
349
|
+
return any(item.strip() == "*" or item.strip().removeprefix("W/") == target
|
|
350
|
+
for item in header.split(","))
|
|
351
|
+
|
|
352
|
+
def _serve_static(self, request: Request, prefix: str, root: Path,
|
|
353
|
+
index: str | None = None,
|
|
354
|
+
cache_control: str = "public, max-age=0, must-revalidate",
|
|
355
|
+
symlinks: str = "deny",
|
|
356
|
+
spa_fallback: str | None = None) -> Response:
|
|
357
|
+
if request.method not in {"GET", "HEAD"}:
|
|
358
|
+
return Response.text("Method Not Allowed", 405, {"Allow": "GET, HEAD, OPTIONS"})
|
|
359
|
+
relative = unquote(request.path[len(prefix):].strip("/"))
|
|
360
|
+
if not relative or "\\" in relative or "\x00" in relative:
|
|
361
|
+
return Response.text("Not Found", 404)
|
|
362
|
+
parts = relative.split("/")
|
|
363
|
+
if any(part in {"", ".", ".."} for part in parts):
|
|
364
|
+
return Response.text("Not Found", 404)
|
|
365
|
+
candidate = root.joinpath(*parts)
|
|
366
|
+
# SPA fallback is restricted to HTML navigation and missing paths.
|
|
367
|
+
if spa_fallback and request.accepts("text/html") and not candidate.exists():
|
|
368
|
+
parts = spa_fallback.split("/")
|
|
369
|
+
candidate = root.joinpath(*parts)
|
|
370
|
+
descriptor = None
|
|
371
|
+
try:
|
|
372
|
+
if symlinks == "deny" and os.open in getattr(os, "supports_dir_fd", set()):
|
|
373
|
+
# Open each path component relative to a trusted directory FD.
|
|
374
|
+
# O_NOFOLLOW on every component prevents symlink-swap traversal.
|
|
375
|
+
nofollow = getattr(os, "O_NOFOLLOW", 0)
|
|
376
|
+
directory_flag = getattr(os, "O_DIRECTORY", 0)
|
|
377
|
+
if not nofollow or not directory_flag:
|
|
378
|
+
raise OSError("No race-resistant no-follow directory traversal")
|
|
379
|
+
root_fd = os.open(root, os.O_RDONLY | directory_flag | getattr(os, "O_BINARY", 0))
|
|
380
|
+
current_fd = root_fd
|
|
381
|
+
try:
|
|
382
|
+
for part in parts[:-1]:
|
|
383
|
+
next_fd = os.open(part, os.O_RDONLY | directory_flag | nofollow |
|
|
384
|
+
getattr(os, "O_BINARY", 0), dir_fd=current_fd)
|
|
385
|
+
if current_fd != root_fd:
|
|
386
|
+
os.close(current_fd)
|
|
387
|
+
current_fd = next_fd
|
|
388
|
+
if index:
|
|
389
|
+
try:
|
|
390
|
+
directory_fd = os.open(parts[-1], os.O_RDONLY | directory_flag | nofollow |
|
|
391
|
+
getattr(os, "O_BINARY", 0), dir_fd=current_fd)
|
|
392
|
+
except NotADirectoryError:
|
|
393
|
+
directory_fd = None
|
|
394
|
+
if directory_fd is not None:
|
|
395
|
+
try:
|
|
396
|
+
descriptor = os.open(index, os.O_RDONLY | nofollow |
|
|
397
|
+
getattr(os, "O_BINARY", 0), dir_fd=directory_fd)
|
|
398
|
+
finally:
|
|
399
|
+
os.close(directory_fd)
|
|
400
|
+
candidate = candidate / index
|
|
401
|
+
else:
|
|
402
|
+
descriptor = os.open(parts[-1], os.O_RDONLY | nofollow |
|
|
403
|
+
getattr(os, "O_BINARY", 0), dir_fd=current_fd)
|
|
404
|
+
else:
|
|
405
|
+
descriptor = os.open(parts[-1], os.O_RDONLY | nofollow |
|
|
406
|
+
getattr(os, "O_BINARY", 0), dir_fd=current_fd)
|
|
407
|
+
finally:
|
|
408
|
+
if current_fd != root_fd:
|
|
409
|
+
os.close(current_fd)
|
|
410
|
+
os.close(root_fd)
|
|
411
|
+
if index and os.path.isdir(candidate):
|
|
412
|
+
candidate = candidate / index
|
|
413
|
+
else:
|
|
414
|
+
# Portable fallback: containment checks and final-component
|
|
415
|
+
# O_NOFOLLOW. Platforms without dir_fd traversal cannot offer
|
|
416
|
+
# the same protection against intermediate-component races.
|
|
417
|
+
if symlinks == "deny":
|
|
418
|
+
current = root
|
|
419
|
+
for part in parts:
|
|
420
|
+
current = current / part
|
|
421
|
+
if current.is_symlink():
|
|
422
|
+
return Response.text("Not Found", 404)
|
|
423
|
+
if candidate.is_dir() and index:
|
|
424
|
+
candidate = candidate / index
|
|
425
|
+
if candidate.is_symlink():
|
|
426
|
+
return Response.text("Not Found", 404)
|
|
427
|
+
else:
|
|
428
|
+
candidate = candidate.resolve(strict=True)
|
|
429
|
+
candidate.relative_to(root)
|
|
430
|
+
if candidate.is_dir() and index:
|
|
431
|
+
candidate = (candidate / index).resolve(strict=True)
|
|
432
|
+
candidate.relative_to(root)
|
|
433
|
+
candidate.parent.resolve(strict=True).relative_to(root)
|
|
434
|
+
flags = os.O_RDONLY | getattr(os, "O_BINARY", 0) | getattr(os, "O_NOFOLLOW", 0)
|
|
435
|
+
descriptor = os.open(candidate, flags)
|
|
436
|
+
file_stat = os.fstat(descriptor)
|
|
437
|
+
stat_module = __import__("stat")
|
|
438
|
+
if stat_module.S_ISDIR(file_stat.st_mode) and symlinks == "deny" and os.open in getattr(os, "supports_dir_fd", set()):
|
|
439
|
+
if not index:
|
|
440
|
+
os.close(descriptor)
|
|
441
|
+
return Response.text("Not Found", 404)
|
|
442
|
+
directory_fd = descriptor
|
|
443
|
+
try:
|
|
444
|
+
descriptor = os.open(index, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) |
|
|
445
|
+
getattr(os, "O_BINARY", 0), dir_fd=directory_fd)
|
|
446
|
+
finally:
|
|
447
|
+
os.close(directory_fd)
|
|
448
|
+
file_stat = os.fstat(descriptor)
|
|
449
|
+
if not stat_module.S_ISREG(file_stat.st_mode):
|
|
450
|
+
os.close(descriptor)
|
|
451
|
+
return Response.text("Not Found", 404)
|
|
452
|
+
except (OSError, ValueError, RuntimeError):
|
|
453
|
+
if descriptor is not None:
|
|
454
|
+
try: os.close(descriptor)
|
|
455
|
+
except OSError: pass
|
|
456
|
+
return Response.text("Not Found", 404)
|
|
457
|
+
|
|
458
|
+
size = file_stat.st_size
|
|
459
|
+
from email.utils import formatdate
|
|
460
|
+
etag = f'W/"{file_stat.st_mtime_ns:x}-{size:x}-{getattr(file_stat, "st_ino", 0):x}"'
|
|
461
|
+
headers = {"ETag": etag, "Last-Modified": formatdate(file_stat.st_mtime, usegmt=True),
|
|
462
|
+
"Cache-Control": cache_control,
|
|
463
|
+
"Content-Type": mimetypes.guess_type(candidate.name)[0] or "application/octet-stream",
|
|
464
|
+
"Accept-Ranges": "bytes"}
|
|
465
|
+
|
|
466
|
+
def close_fd():
|
|
467
|
+
try: os.close(descriptor)
|
|
468
|
+
except OSError: pass
|
|
469
|
+
|
|
470
|
+
inm = request.headers.get("if-none-match")
|
|
471
|
+
if inm is not None:
|
|
472
|
+
if self._etag_matches(inm, etag):
|
|
473
|
+
close_fd()
|
|
474
|
+
return Response(b"", 304, headers)
|
|
475
|
+
else:
|
|
476
|
+
ims = request.headers.get("if-modified-since")
|
|
477
|
+
if ims:
|
|
478
|
+
try:
|
|
479
|
+
from email.utils import parsedate_to_datetime
|
|
480
|
+
from datetime import timezone
|
|
481
|
+
date = parsedate_to_datetime(ims)
|
|
482
|
+
if date.tzinfo is None: date = date.replace(tzinfo=timezone.utc)
|
|
483
|
+
if int(file_stat.st_mtime) <= int(date.timestamp()):
|
|
484
|
+
close_fd()
|
|
485
|
+
return Response(b"", 304, headers)
|
|
486
|
+
except (TypeError, ValueError, OverflowError):
|
|
487
|
+
pass
|
|
488
|
+
|
|
489
|
+
range_header = request.headers.get("range")
|
|
490
|
+
if_range = request.headers.get("if-range")
|
|
491
|
+
if range_header and if_range:
|
|
492
|
+
if if_range.startswith('"') or if_range.startswith("W/"):
|
|
493
|
+
if if_range != etag or etag.startswith("W/"): range_header = None
|
|
494
|
+
else:
|
|
495
|
+
try:
|
|
496
|
+
from email.utils import parsedate_to_datetime
|
|
497
|
+
from datetime import timezone
|
|
498
|
+
date = parsedate_to_datetime(if_range)
|
|
499
|
+
if date.tzinfo is None: date = date.replace(tzinfo=timezone.utc)
|
|
500
|
+
if int(file_stat.st_mtime) > int(date.timestamp()): range_header = None
|
|
501
|
+
except (TypeError, ValueError, OverflowError):
|
|
502
|
+
range_header = None
|
|
503
|
+
|
|
504
|
+
start, end, status = 0, size - 1, 200
|
|
505
|
+
if range_header:
|
|
506
|
+
# Ignore unsupported units, malformed syntax and multi-range requests.
|
|
507
|
+
# Return 416 only for a valid byte range that cannot be satisfied.
|
|
508
|
+
match = __import__("re").fullmatch(r"bytes=(\d*)-(\d*)", range_header.strip())
|
|
509
|
+
if match:
|
|
510
|
+
first, last = match.groups()
|
|
511
|
+
if first or last:
|
|
512
|
+
try:
|
|
513
|
+
if not first:
|
|
514
|
+
suffix = int(last)
|
|
515
|
+
if suffix <= 0 or size == 0:
|
|
516
|
+
raise IndexError
|
|
517
|
+
start, end, status = max(0, size - suffix), size - 1, 206
|
|
518
|
+
else:
|
|
519
|
+
start = int(first)
|
|
520
|
+
end = int(last) if last else size - 1
|
|
521
|
+
if start >= size or end < start or size == 0:
|
|
522
|
+
raise IndexError
|
|
523
|
+
end = min(end, size - 1)
|
|
524
|
+
status = 206
|
|
525
|
+
except IndexError:
|
|
526
|
+
close_fd()
|
|
527
|
+
return Response(b"", 416, {**headers, "Content-Range": f"bytes */{size}"})
|
|
528
|
+
except (ValueError, OverflowError):
|
|
529
|
+
# Reject pathological integer fields by ignoring Range.
|
|
530
|
+
start, end, status = 0, size - 1, 200
|
|
531
|
+
if status == 206:
|
|
532
|
+
headers["Content-Range"] = f"bytes {start}-{end}/{size}"
|
|
533
|
+
|
|
534
|
+
length = max(0, end - start + 1)
|
|
535
|
+
headers["Content-Length"] = str(length)
|
|
536
|
+
if status == 206: os.lseek(descriptor, start, os.SEEK_SET)
|
|
537
|
+
if request.method == "HEAD":
|
|
538
|
+
close_fd()
|
|
539
|
+
return Response(b"", status, headers)
|
|
540
|
+
return Response(_FileDescriptorStream(descriptor, length), status, headers)
|
|
541
|
+
|
|
542
|
+
def run(self, host: str = "127.0.0.1", port: int = 8000, *, request_timeout: float = 15.0,
|
|
543
|
+
max_header_bytes: int = 32768, trusted_hosts: tuple[str, ...] | list[str] | None = None) -> None:
|
|
544
|
+
if self.background_tasks.closed:
|
|
545
|
+
self.background_tasks = BackgroundTasks()
|
|
546
|
+
server = LimboServer(self.dispatch, host, port, request_timeout=request_timeout,
|
|
547
|
+
max_header_bytes=max_header_bytes, max_body_bytes=self.max_body_bytes,
|
|
548
|
+
trusted_hosts=trusted_hosts, websocket_handler=self.dispatch_websocket if self.websocket_routes else None,
|
|
549
|
+
websocket_origin_check=self.websocket_origin_allowed,
|
|
550
|
+
websocket_options=self.websocket_options, upload_limits=self.upload_limits)
|
|
551
|
+
self._server = server
|
|
552
|
+
try:
|
|
553
|
+
for hook in self.startup_hooks:
|
|
554
|
+
hook()
|
|
555
|
+
server.serve_forever()
|
|
556
|
+
except KeyboardInterrupt:
|
|
557
|
+
server.shutdown()
|
|
558
|
+
finally:
|
|
559
|
+
server.shutdown()
|
|
560
|
+
try:
|
|
561
|
+
for hook in reversed(self.shutdown_hooks):
|
|
562
|
+
hook()
|
|
563
|
+
finally:
|
|
564
|
+
self.background_tasks.shutdown(wait=True, cancel_pending=True)
|
|
565
|
+
self._server = None
|
limbo/auth.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Authentication and authorization middleware extension points."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Any, Callable
|
|
5
|
+
|
|
6
|
+
from .http import Request, Response
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def authenticate(resolve_user: Callable[[Request], Any | None], *,
|
|
10
|
+
unauthorized: Callable[[Request], Response] | None = None) -> Callable:
|
|
11
|
+
"""Resolve a user for each request and attach it as request.user.
|
|
12
|
+
|
|
13
|
+
The resolver owns credential verification (for example, session lookup or
|
|
14
|
+
an API token). This helper deliberately does not prescribe an identity
|
|
15
|
+
provider, password database, or authentication protocol.
|
|
16
|
+
"""
|
|
17
|
+
if not callable(resolve_user):
|
|
18
|
+
raise TypeError("resolve_user must be callable")
|
|
19
|
+
|
|
20
|
+
def middleware(request: Request, next_handler: Callable) -> Response:
|
|
21
|
+
user = resolve_user(request)
|
|
22
|
+
if user is None:
|
|
23
|
+
return unauthorized(request) if unauthorized else Response.text("Unauthorized", 401)
|
|
24
|
+
request.user = user
|
|
25
|
+
return next_handler(request)
|
|
26
|
+
return middleware
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def authorize(check: Callable[[Request, Any], bool], *,
|
|
30
|
+
forbidden: Callable[[Request], Response] | None = None) -> Callable:
|
|
31
|
+
"""Apply an application-defined authorization predicate to request.user."""
|
|
32
|
+
if not callable(check):
|
|
33
|
+
raise TypeError("authorization check must be callable")
|
|
34
|
+
|
|
35
|
+
def middleware(request: Request, next_handler: Callable) -> Response:
|
|
36
|
+
user = getattr(request, "user", None)
|
|
37
|
+
if user is None:
|
|
38
|
+
return Response.text("Unauthorized", 401)
|
|
39
|
+
if not check(request, user):
|
|
40
|
+
return forbidden(request) if forbidden else Response.text("Forbidden", 403)
|
|
41
|
+
return next_handler(request)
|
|
42
|
+
return middleware
|