webdev 2.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.
- webdev/__init__.py +30 -0
- webdev/__main__.py +29 -0
- webdev/cli.py +330 -0
- webdev/config.py +210 -0
- webdev/index.py +298 -0
- webdev/livereload.py +169 -0
- webdev/net.py +145 -0
- webdev/run.py +124 -0
- webdev/server.py +336 -0
- webdev/static.py +195 -0
- webdev/templates/dark/base.html.jinja2 +17 -0
- webdev/templates/dark/index.html.jinja2 +71 -0
- webdev/templates/dark/partials/sort.js.jinja2 +52 -0
- webdev/templates/dark/partials/styles.css.jinja2 +110 -0
- webdev/tls.py +159 -0
- webdev-2.0.0.dist-info/METADATA +240 -0
- webdev-2.0.0.dist-info/RECORD +20 -0
- webdev-2.0.0.dist-info/WHEEL +4 -0
- webdev-2.0.0.dist-info/entry_points.txt +2 -0
- webdev-2.0.0.dist-info/licenses/LICENSE +202 -0
webdev/run.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# Copyright (C) 2026 KuraLabs S.R.L <info@kuralabs.io>
|
|
2
|
+
# Copyright (C) 2015-2016 Carlos Jenkins <carlos@jenkins.co.cr>
|
|
3
|
+
#
|
|
4
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
# you may not use this file except in compliance with the License.
|
|
6
|
+
# You may obtain a copy of the License at
|
|
7
|
+
#
|
|
8
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
#
|
|
10
|
+
# Unless required by applicable law or agreed to in writing,
|
|
11
|
+
# software distributed under the License is distributed on an
|
|
12
|
+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
13
|
+
# KIND, either express or implied. See the License for the
|
|
14
|
+
# specific language governing permissions and limitations
|
|
15
|
+
# under the License.
|
|
16
|
+
|
|
17
|
+
"""
|
|
18
|
+
High-level runner: select a port, build the app, print the banner and start
|
|
19
|
+
Uvicorn (optionally over HTTPS).
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import logging
|
|
25
|
+
|
|
26
|
+
import uvicorn
|
|
27
|
+
|
|
28
|
+
from .config import Settings
|
|
29
|
+
from .net import NoFreePortError, build_urls, find_free_port, render_qr
|
|
30
|
+
from .server import create_app
|
|
31
|
+
from .tls import ensure_tls_files
|
|
32
|
+
|
|
33
|
+
log = logging.getLogger('webdev')
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _print_banner(settings: Settings, port: int) -> None:
|
|
37
|
+
"""
|
|
38
|
+
Print the startup banner with the served path and access URLs.
|
|
39
|
+
|
|
40
|
+
Builds the list of reachable URLs, prints the bound address and each URL,
|
|
41
|
+
optionally renders a QR code for the last URL, and reminds the user how to
|
|
42
|
+
stop the server.
|
|
43
|
+
|
|
44
|
+
:param settings: Normalized runtime settings describing the server.
|
|
45
|
+
:param port: The resolved TCP port the server is bound to.
|
|
46
|
+
"""
|
|
47
|
+
urls = build_urls(settings.ip, port, settings.https)
|
|
48
|
+
print('WebDev {} serving {}'.format(_version(), settings.path))
|
|
49
|
+
print(f'Bound to {settings.ip}:{port}')
|
|
50
|
+
for url in urls:
|
|
51
|
+
print(f' -> {url}')
|
|
52
|
+
if settings.qr and urls:
|
|
53
|
+
qr = render_qr(urls[-1])
|
|
54
|
+
if qr:
|
|
55
|
+
print(qr)
|
|
56
|
+
print('Press Ctrl+C to stop.')
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _version() -> str:
|
|
60
|
+
"""
|
|
61
|
+
Return the installed WebDev package version string.
|
|
62
|
+
|
|
63
|
+
:return: The value of :data:`webdev.__version__`.
|
|
64
|
+
"""
|
|
65
|
+
from . import __version__
|
|
66
|
+
|
|
67
|
+
return __version__
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def serve(settings: Settings) -> int:
|
|
71
|
+
"""
|
|
72
|
+
Resolve a port, build the app and run the server.
|
|
73
|
+
|
|
74
|
+
Normalizes the given settings, selects a free port, optionally prepares
|
|
75
|
+
TLS certificate files for HTTPS, builds the ASGI application, prints the
|
|
76
|
+
startup banner and runs Uvicorn until interrupted.
|
|
77
|
+
|
|
78
|
+
:param settings: The runtime settings controlling how the server runs.
|
|
79
|
+
:return: A process exit code: ``0`` on success, ``1`` on startup failure.
|
|
80
|
+
"""
|
|
81
|
+
settings = settings.normalized()
|
|
82
|
+
|
|
83
|
+
try:
|
|
84
|
+
port = find_free_port(settings.ip, settings.port, settings.range)
|
|
85
|
+
except NoFreePortError as error:
|
|
86
|
+
log.error(str(error))
|
|
87
|
+
return 1
|
|
88
|
+
|
|
89
|
+
cert_file: str | None = None
|
|
90
|
+
key_file: str | None = None
|
|
91
|
+
if settings.https:
|
|
92
|
+
from .tls import TLSUnavailableError
|
|
93
|
+
|
|
94
|
+
try:
|
|
95
|
+
cert_file, key_file = ensure_tls_files(settings.cert, settings.key)
|
|
96
|
+
except TLSUnavailableError as error:
|
|
97
|
+
log.error(str(error))
|
|
98
|
+
return 1
|
|
99
|
+
|
|
100
|
+
app = create_app(settings)
|
|
101
|
+
|
|
102
|
+
_print_banner(settings, port)
|
|
103
|
+
|
|
104
|
+
log_level = (
|
|
105
|
+
'debug'
|
|
106
|
+
if settings.verbose >= 3
|
|
107
|
+
else ('info' if settings.verbose >= 2 else 'warning')
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
config = uvicorn.Config(
|
|
111
|
+
app,
|
|
112
|
+
host=settings.ip,
|
|
113
|
+
port=port,
|
|
114
|
+
log_level=log_level,
|
|
115
|
+
access_log=settings.verbose >= 2,
|
|
116
|
+
ssl_certfile=cert_file,
|
|
117
|
+
ssl_keyfile=key_file,
|
|
118
|
+
)
|
|
119
|
+
server = uvicorn.Server(config)
|
|
120
|
+
try:
|
|
121
|
+
server.run()
|
|
122
|
+
except KeyboardInterrupt: # pragma: no cover - handled by uvicorn too
|
|
123
|
+
print('Good bye!')
|
|
124
|
+
return 0
|
webdev/server.py
ADDED
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
# Copyright (C) 2026 KuraLabs S.R.L <info@kuralabs.io>
|
|
2
|
+
# Copyright (C) 2015-2016 Carlos Jenkins <carlos@jenkins.co.cr>
|
|
3
|
+
#
|
|
4
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
# you may not use this file except in compliance with the License.
|
|
6
|
+
# You may obtain a copy of the License at
|
|
7
|
+
#
|
|
8
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
#
|
|
10
|
+
# Unless required by applicable law or agreed to in writing,
|
|
11
|
+
# software distributed under the License is distributed on an
|
|
12
|
+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
13
|
+
# KIND, either express or implied. See the License for the
|
|
14
|
+
# specific language governing permissions and limitations
|
|
15
|
+
# under the License.
|
|
16
|
+
|
|
17
|
+
"""
|
|
18
|
+
Starlette application factory and Uvicorn runner.
|
|
19
|
+
|
|
20
|
+
Builds the ASGI application that serves static files and directory listings,
|
|
21
|
+
optionally with live reload, gzip, CORS, SPA fallback, and no-cache headers,
|
|
22
|
+
based on a resolved :class:`~webdev.config.Settings` instance.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import asyncio
|
|
28
|
+
import contextlib
|
|
29
|
+
import logging
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
|
|
32
|
+
from starlette.applications import Starlette
|
|
33
|
+
from starlette.middleware import Middleware
|
|
34
|
+
from starlette.middleware.cors import CORSMiddleware
|
|
35
|
+
from starlette.middleware.gzip import GZipMiddleware
|
|
36
|
+
from starlette.requests import Request
|
|
37
|
+
from starlette.responses import (
|
|
38
|
+
FileResponse,
|
|
39
|
+
HTMLResponse,
|
|
40
|
+
RedirectResponse,
|
|
41
|
+
Response,
|
|
42
|
+
)
|
|
43
|
+
from starlette.routing import Route, WebSocketRoute
|
|
44
|
+
from starlette.websockets import WebSocket, WebSocketDisconnect
|
|
45
|
+
|
|
46
|
+
from .config import Settings
|
|
47
|
+
from .index import render_index, validate_theme
|
|
48
|
+
from .livereload import (
|
|
49
|
+
CLIENT_SNIPLET,
|
|
50
|
+
LIVERELOAD_PATH,
|
|
51
|
+
ReloadHub,
|
|
52
|
+
watch_tree,
|
|
53
|
+
)
|
|
54
|
+
from .static import (
|
|
55
|
+
NO_CACHE_HEADERS,
|
|
56
|
+
find_index_file,
|
|
57
|
+
guess_media_type,
|
|
58
|
+
resolve_fs_path,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
log = logging.getLogger('webdev')
|
|
62
|
+
|
|
63
|
+
_HTML_SUFFIXES = ('.html', '.htm')
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _inject_livereload(html: bytes, enabled: bool) -> bytes:
|
|
67
|
+
"""
|
|
68
|
+
Inject the reload snippet before ``</body>`` (or append) when enabled.
|
|
69
|
+
|
|
70
|
+
When *enabled* is false the HTML is returned unchanged. Otherwise the
|
|
71
|
+
client snippet is inserted just before the last ``</body>`` tag, or appended
|
|
72
|
+
if no such tag exists.
|
|
73
|
+
|
|
74
|
+
:param html: The HTML document bytes to augment.
|
|
75
|
+
:param enabled: Whether live reload injection is active.
|
|
76
|
+
:return: The HTML bytes, with the snippet injected when *enabled*.
|
|
77
|
+
"""
|
|
78
|
+
if not enabled:
|
|
79
|
+
return html
|
|
80
|
+
snippet = CLIENT_SNIPLET.encode('utf-8')
|
|
81
|
+
lowered = html.lower()
|
|
82
|
+
idx = lowered.rfind(b'</body>')
|
|
83
|
+
if idx != -1:
|
|
84
|
+
return html[:idx] + snippet + html[idx:]
|
|
85
|
+
return html + snippet
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _cache_headers(settings: Settings) -> dict[str, str]:
|
|
89
|
+
"""
|
|
90
|
+
Return response headers that control client caching.
|
|
91
|
+
|
|
92
|
+
:param settings: The resolved runtime settings.
|
|
93
|
+
:return: No-cache headers when caching is disabled, else an empty mapping.
|
|
94
|
+
"""
|
|
95
|
+
return dict(NO_CACHE_HEADERS) if settings.no_cache else {}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _not_found(settings: Settings) -> Response:
|
|
99
|
+
"""
|
|
100
|
+
Build a plain-text ``404 Not Found`` response.
|
|
101
|
+
|
|
102
|
+
:param settings: The resolved runtime settings.
|
|
103
|
+
:return: A :class:`Response` with status code 404.
|
|
104
|
+
"""
|
|
105
|
+
return Response('404 Not Found', status_code=404, media_type='text/plain')
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _html_response(
|
|
109
|
+
body: bytes, settings: Settings, status_code: int = 200
|
|
110
|
+
) -> HTMLResponse:
|
|
111
|
+
"""
|
|
112
|
+
Build an HTML response with live-reload injection and cache headers.
|
|
113
|
+
|
|
114
|
+
:param body: The raw HTML document bytes.
|
|
115
|
+
:param settings: The resolved runtime settings.
|
|
116
|
+
:param status_code: HTTP status code for the response.
|
|
117
|
+
:return: An :class:`HTMLResponse` ready to be returned to the client.
|
|
118
|
+
"""
|
|
119
|
+
body = _inject_livereload(body, settings.reload)
|
|
120
|
+
return HTMLResponse(body, status_code=status_code, headers=_cache_headers(settings))
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
async def _serve_file(path: Path, settings: Settings) -> Response:
|
|
124
|
+
"""
|
|
125
|
+
Serve a regular file, injecting live-reload JS into HTML documents.
|
|
126
|
+
|
|
127
|
+
HTML files are read into memory so the reload snippet can be injected;
|
|
128
|
+
other files are streamed via :class:`FileResponse`.
|
|
129
|
+
|
|
130
|
+
:param path: Filesystem path of the file to serve.
|
|
131
|
+
:param settings: The resolved runtime settings.
|
|
132
|
+
:return: An HTML, file, or 404 response depending on the file and I/O.
|
|
133
|
+
"""
|
|
134
|
+
if path.suffix.lower() in _HTML_SUFFIXES:
|
|
135
|
+
try:
|
|
136
|
+
body = path.read_bytes()
|
|
137
|
+
except OSError:
|
|
138
|
+
return _not_found(settings)
|
|
139
|
+
return _html_response(body, settings)
|
|
140
|
+
return FileResponse(
|
|
141
|
+
path,
|
|
142
|
+
media_type=guess_media_type(path),
|
|
143
|
+
headers=_cache_headers(settings),
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
async def _serve_directory(
|
|
148
|
+
request: Request, fs_dir: Path, settings: Settings
|
|
149
|
+
) -> Response:
|
|
150
|
+
"""
|
|
151
|
+
Serve a directory as an index page or its index file.
|
|
152
|
+
|
|
153
|
+
Redirects to a trailing-slash URL when needed, serves a directory index
|
|
154
|
+
file if present, and otherwise renders a themed directory listing.
|
|
155
|
+
|
|
156
|
+
:param request: The incoming HTTP request.
|
|
157
|
+
:param fs_dir: Filesystem path of the directory to serve.
|
|
158
|
+
:param settings: The resolved runtime settings.
|
|
159
|
+
:return: A redirect, file, or rendered index HTML response.
|
|
160
|
+
"""
|
|
161
|
+
url_path = request.url.path
|
|
162
|
+
# Ensure a trailing slash so relative links resolve correctly.
|
|
163
|
+
if not url_path.endswith('/'):
|
|
164
|
+
return RedirectResponse(url_path + '/', status_code=308)
|
|
165
|
+
|
|
166
|
+
index_file = find_index_file(fs_dir)
|
|
167
|
+
if index_file is not None:
|
|
168
|
+
return await _serve_file(index_file, settings)
|
|
169
|
+
|
|
170
|
+
html = render_index(
|
|
171
|
+
fs_dir=fs_dir,
|
|
172
|
+
url_path=url_path,
|
|
173
|
+
served_root=Path(settings.path),
|
|
174
|
+
theme=settings.theme,
|
|
175
|
+
live_reload=settings.reload,
|
|
176
|
+
)
|
|
177
|
+
return _html_response(html.encode('utf-8'), settings)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
async def _handle(request: Request) -> Response:
|
|
181
|
+
"""
|
|
182
|
+
Main request handler resolving a URL path to a file or directory.
|
|
183
|
+
|
|
184
|
+
Rejects paths that escape the served root, applies the optional SPA
|
|
185
|
+
fallback for unknown routes, and dispatches to the file or directory
|
|
186
|
+
handler as appropriate.
|
|
187
|
+
|
|
188
|
+
:param request: The incoming HTTP request.
|
|
189
|
+
:return: The response for the resolved resource, or a 404 response.
|
|
190
|
+
"""
|
|
191
|
+
settings: Settings = request.app.state.settings
|
|
192
|
+
root = Path(settings.path)
|
|
193
|
+
resolved = resolve_fs_path(root, request.url.path)
|
|
194
|
+
|
|
195
|
+
if resolved.escapes:
|
|
196
|
+
return _not_found(settings)
|
|
197
|
+
|
|
198
|
+
if resolved.fs_path is None:
|
|
199
|
+
# SPA fallback: serve the root index.html for unknown routes.
|
|
200
|
+
if settings.spa:
|
|
201
|
+
index_file = find_index_file(root)
|
|
202
|
+
if index_file is not None:
|
|
203
|
+
return await _serve_file(index_file, settings)
|
|
204
|
+
return _not_found(settings)
|
|
205
|
+
|
|
206
|
+
if resolved.is_dir:
|
|
207
|
+
return await _serve_directory(request, resolved.fs_path, settings)
|
|
208
|
+
|
|
209
|
+
return await _serve_file(resolved.fs_path, settings)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
async def _drain(websocket: WebSocket) -> None:
|
|
213
|
+
"""
|
|
214
|
+
Consume inbound frames so client disconnects are detected.
|
|
215
|
+
|
|
216
|
+
Runs until the client disconnects, suppressing the resulting
|
|
217
|
+
:class:`WebSocketDisconnect`.
|
|
218
|
+
|
|
219
|
+
:param websocket: The WebSocket connection to drain.
|
|
220
|
+
"""
|
|
221
|
+
with contextlib.suppress(WebSocketDisconnect):
|
|
222
|
+
while True:
|
|
223
|
+
await websocket.receive_text()
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
async def _livereload_endpoint(websocket: WebSocket) -> None:
|
|
227
|
+
"""
|
|
228
|
+
WebSocket endpoint that pushes live-reload messages to the browser.
|
|
229
|
+
|
|
230
|
+
Registers with the reload hub, forwards each reload message to the client,
|
|
231
|
+
and stops when the client disconnects, cleaning up its registration.
|
|
232
|
+
|
|
233
|
+
:param websocket: The incoming WebSocket connection.
|
|
234
|
+
"""
|
|
235
|
+
hub: ReloadHub = websocket.app.state.hub
|
|
236
|
+
await websocket.accept()
|
|
237
|
+
queue = hub.register()
|
|
238
|
+
receiver = asyncio.create_task(_drain(websocket))
|
|
239
|
+
try:
|
|
240
|
+
while True:
|
|
241
|
+
getter = asyncio.create_task(queue.get())
|
|
242
|
+
done, _pending = await asyncio.wait(
|
|
243
|
+
{getter, receiver}, return_when=asyncio.FIRST_COMPLETED
|
|
244
|
+
)
|
|
245
|
+
if receiver in done:
|
|
246
|
+
getter.cancel()
|
|
247
|
+
break
|
|
248
|
+
message = getter.result()
|
|
249
|
+
await websocket.send_text(message)
|
|
250
|
+
except WebSocketDisconnect:
|
|
251
|
+
pass
|
|
252
|
+
finally:
|
|
253
|
+
receiver.cancel()
|
|
254
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
255
|
+
await receiver
|
|
256
|
+
hub.unregister(queue)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _build_lifespan(settings: Settings):
|
|
260
|
+
"""
|
|
261
|
+
Create an ASGI lifespan context manager bound to *settings*.
|
|
262
|
+
|
|
263
|
+
The returned lifespan stores settings and a reload hub on the application
|
|
264
|
+
state and, when live reload is enabled, runs a filesystem watcher for the
|
|
265
|
+
lifetime of the application.
|
|
266
|
+
|
|
267
|
+
:param settings: The resolved runtime settings.
|
|
268
|
+
:return: An async lifespan context manager for :class:`Starlette`.
|
|
269
|
+
"""
|
|
270
|
+
|
|
271
|
+
@contextlib.asynccontextmanager
|
|
272
|
+
async def lifespan(app: Starlette):
|
|
273
|
+
"""
|
|
274
|
+
Manage startup and shutdown of application-wide resources.
|
|
275
|
+
|
|
276
|
+
:param app: The Starlette application being started.
|
|
277
|
+
"""
|
|
278
|
+
app.state.settings = settings
|
|
279
|
+
app.state.hub = ReloadHub()
|
|
280
|
+
stop_event = asyncio.Event()
|
|
281
|
+
watcher: asyncio.Task | None = None
|
|
282
|
+
if settings.reload:
|
|
283
|
+
watcher = asyncio.create_task(
|
|
284
|
+
watch_tree(Path(settings.path), app.state.hub, stop_event)
|
|
285
|
+
)
|
|
286
|
+
try:
|
|
287
|
+
yield
|
|
288
|
+
finally:
|
|
289
|
+
stop_event.set()
|
|
290
|
+
if watcher is not None:
|
|
291
|
+
watcher.cancel()
|
|
292
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
293
|
+
await watcher
|
|
294
|
+
|
|
295
|
+
return lifespan
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def create_app(settings: Settings) -> Starlette:
|
|
299
|
+
"""
|
|
300
|
+
Build the Starlette application for the given *settings*.
|
|
301
|
+
|
|
302
|
+
Validates the theme, wires up routes (including the live-reload WebSocket
|
|
303
|
+
when enabled) and optional CORS and gzip middleware, and attaches the
|
|
304
|
+
settings to the application state.
|
|
305
|
+
|
|
306
|
+
:param settings: The resolved runtime settings.
|
|
307
|
+
:return: A configured :class:`Starlette` application instance.
|
|
308
|
+
:raises ThemeNotFoundError: If the configured theme does not exist.
|
|
309
|
+
"""
|
|
310
|
+
validate_theme(settings.theme)
|
|
311
|
+
|
|
312
|
+
routes: list = []
|
|
313
|
+
if settings.reload:
|
|
314
|
+
routes.append(WebSocketRoute(LIVERELOAD_PATH, _livereload_endpoint))
|
|
315
|
+
routes.append(Route('/{path:path}', _handle, methods=['GET', 'HEAD']))
|
|
316
|
+
|
|
317
|
+
middleware: list[Middleware] = []
|
|
318
|
+
if settings.cors:
|
|
319
|
+
middleware.append(
|
|
320
|
+
Middleware(
|
|
321
|
+
CORSMiddleware,
|
|
322
|
+
allow_origins=['*'],
|
|
323
|
+
allow_methods=['*'],
|
|
324
|
+
allow_headers=['*'],
|
|
325
|
+
)
|
|
326
|
+
)
|
|
327
|
+
if settings.gzip:
|
|
328
|
+
middleware.append(Middleware(GZipMiddleware, minimum_size=500))
|
|
329
|
+
|
|
330
|
+
app = Starlette(
|
|
331
|
+
routes=routes,
|
|
332
|
+
middleware=middleware,
|
|
333
|
+
lifespan=_build_lifespan(settings),
|
|
334
|
+
)
|
|
335
|
+
app.state.settings = settings
|
|
336
|
+
return app
|
webdev/static.py
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
# Copyright (C) 2026 KuraLabs S.R.L <info@kuralabs.io>
|
|
2
|
+
# Copyright (C) 2015-2016 Carlos Jenkins <carlos@jenkins.co.cr>
|
|
3
|
+
#
|
|
4
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
# you may not use this file except in compliance with the License.
|
|
6
|
+
# You may obtain a copy of the License at
|
|
7
|
+
#
|
|
8
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
#
|
|
10
|
+
# Unless required by applicable law or agreed to in writing,
|
|
11
|
+
# software distributed under the License is distributed on an
|
|
12
|
+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
13
|
+
# KIND, either express or implied. See the License for the
|
|
14
|
+
# specific language governing permissions and limitations
|
|
15
|
+
# under the License.
|
|
16
|
+
|
|
17
|
+
"""
|
|
18
|
+
Per-request static file resolution.
|
|
19
|
+
|
|
20
|
+
The core fix over the legacy server: paths are resolved against the **live
|
|
21
|
+
filesystem on every request** instead of relying on the process working
|
|
22
|
+
directory. A missing served directory therefore yields a real ``404`` and
|
|
23
|
+
recovers automatically the instant the directory reappears.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import mimetypes
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
|
|
31
|
+
#: Candidate index file names auto-served for a directory.
|
|
32
|
+
INDEX_FILENAMES = ('index.html', 'index.htm')
|
|
33
|
+
|
|
34
|
+
#: Extra extension -> MIME mappings the stdlib ``mimetypes`` module omits.
|
|
35
|
+
#: Registered so common dev/config/source files render inline instead of being
|
|
36
|
+
#: downloaded as ``application/octet-stream``.
|
|
37
|
+
EXTRA_MIME_TYPES = {
|
|
38
|
+
'.toml': 'text/plain',
|
|
39
|
+
'.ini': 'text/plain',
|
|
40
|
+
'.cfg': 'text/plain',
|
|
41
|
+
'.conf': 'text/plain',
|
|
42
|
+
'.lock': 'text/plain',
|
|
43
|
+
'.log': 'text/plain',
|
|
44
|
+
'.env': 'text/plain',
|
|
45
|
+
'.properties': 'text/plain',
|
|
46
|
+
'.rst': 'text/plain',
|
|
47
|
+
'.tsx': 'text/plain',
|
|
48
|
+
'.jsx': 'text/plain',
|
|
49
|
+
'.vue': 'text/plain',
|
|
50
|
+
'.svelte': 'text/plain',
|
|
51
|
+
'.yml': 'text/plain',
|
|
52
|
+
'.yaml': 'text/plain',
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
#: Number of bytes inspected when sniffing whether an unknown file is text.
|
|
56
|
+
_SNIFF_BYTES = 8192
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def register_mime_types() -> None:
|
|
60
|
+
"""
|
|
61
|
+
Register :data:`EXTRA_MIME_TYPES` with the stdlib ``mimetypes`` module.
|
|
62
|
+
|
|
63
|
+
Adds each extension-to-media-type mapping so later lookups via
|
|
64
|
+
``mimetypes.guess_type`` recognize these developer-oriented file types.
|
|
65
|
+
"""
|
|
66
|
+
for ext, media_type in EXTRA_MIME_TYPES.items():
|
|
67
|
+
mimetypes.add_type(media_type, ext)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
register_mime_types()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _looks_like_text(path: Path) -> bool:
|
|
74
|
+
"""
|
|
75
|
+
Heuristically decide whether *path* holds UTF-8 text (no NUL bytes).
|
|
76
|
+
|
|
77
|
+
Reads a leading chunk of the file and treats it as text when it contains no
|
|
78
|
+
NUL byte and decodes as UTF-8, tolerating a multi-byte sequence truncated at
|
|
79
|
+
the chunk boundary. Unreadable files are reported as non-text.
|
|
80
|
+
|
|
81
|
+
:param path: The file to inspect.
|
|
82
|
+
:return: ``True`` if the file appears to be UTF-8 text, else ``False``.
|
|
83
|
+
"""
|
|
84
|
+
try:
|
|
85
|
+
with path.open('rb') as fd:
|
|
86
|
+
chunk = fd.read(_SNIFF_BYTES)
|
|
87
|
+
except OSError:
|
|
88
|
+
return False
|
|
89
|
+
if b'\x00' in chunk:
|
|
90
|
+
return False
|
|
91
|
+
try:
|
|
92
|
+
chunk.decode('utf-8')
|
|
93
|
+
except UnicodeDecodeError:
|
|
94
|
+
# A truncated multi-byte sequence at the boundary is still fine.
|
|
95
|
+
try:
|
|
96
|
+
chunk[: chunk.rfind(b' ') + 1 or len(chunk)].decode('utf-8')
|
|
97
|
+
except UnicodeDecodeError:
|
|
98
|
+
return False
|
|
99
|
+
return True
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def guess_media_type(path: Path) -> str:
|
|
103
|
+
"""
|
|
104
|
+
Resolve a response ``Content-Type`` for *path*.
|
|
105
|
+
|
|
106
|
+
Uses the stdlib ``mimetypes`` registry (augmented by
|
|
107
|
+
:func:`register_mime_types`). For still-unknown extensions, sniffs the file
|
|
108
|
+
and falls back to ``text/plain`` when it looks like text, otherwise
|
|
109
|
+
``application/octet-stream`` — so arbitrary text files preview inline while
|
|
110
|
+
binaries still download.
|
|
111
|
+
|
|
112
|
+
:param path: The file whose media type should be determined.
|
|
113
|
+
:return: The resolved MIME type string.
|
|
114
|
+
"""
|
|
115
|
+
media_type, _encoding = mimetypes.guess_type(path.name)
|
|
116
|
+
if media_type:
|
|
117
|
+
return media_type
|
|
118
|
+
if _looks_like_text(path):
|
|
119
|
+
return 'text/plain'
|
|
120
|
+
return 'application/octet-stream'
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
#: Headers that disable caching for fresh dev content.
|
|
124
|
+
NO_CACHE_HEADERS = {
|
|
125
|
+
'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0',
|
|
126
|
+
'Pragma': 'no-cache',
|
|
127
|
+
'Expires': '0',
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class ResolvedPath:
|
|
132
|
+
"""
|
|
133
|
+
Result of resolving a URL path against the served root.
|
|
134
|
+
"""
|
|
135
|
+
|
|
136
|
+
__slots__ = ('fs_path', 'is_dir', 'escapes')
|
|
137
|
+
|
|
138
|
+
def __init__(self, fs_path: Path | None, is_dir: bool, escapes: bool) -> None:
|
|
139
|
+
"""
|
|
140
|
+
Initialize the resolution result.
|
|
141
|
+
|
|
142
|
+
:param fs_path: The resolved filesystem path, or ``None`` if it does
|
|
143
|
+
not exist.
|
|
144
|
+
:param is_dir: Whether the resolved path is a directory.
|
|
145
|
+
:param escapes: Whether the request attempted to traverse outside the
|
|
146
|
+
served root.
|
|
147
|
+
"""
|
|
148
|
+
self.fs_path = fs_path
|
|
149
|
+
self.is_dir = is_dir
|
|
150
|
+
self.escapes = escapes
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def resolve_fs_path(root: Path, url_path: str) -> ResolvedPath:
|
|
154
|
+
"""
|
|
155
|
+
Resolve *url_path* (the request path) to a filesystem path under *root*.
|
|
156
|
+
|
|
157
|
+
Returns a :class:`ResolvedPath`. ``escapes`` is ``True`` when the request
|
|
158
|
+
attempted to traverse outside *root* (the caller should reject with 404).
|
|
159
|
+
``fs_path`` is ``None`` when the target does not exist.
|
|
160
|
+
|
|
161
|
+
:param root: The served root directory.
|
|
162
|
+
:param url_path: The decoded request URL path to resolve.
|
|
163
|
+
:return: The resolution result describing the target path.
|
|
164
|
+
"""
|
|
165
|
+
# Normalise: strip leading slash, decode nothing here (Starlette already
|
|
166
|
+
# provides a decoded path), collapse via Path semantics.
|
|
167
|
+
relative = url_path.lstrip('/')
|
|
168
|
+
root_resolved = root.resolve(strict=False)
|
|
169
|
+
candidate = (root_resolved / relative).resolve(strict=False)
|
|
170
|
+
|
|
171
|
+
# Traversal safety: the resolved candidate must live under the root.
|
|
172
|
+
if candidate != root_resolved and root_resolved not in candidate.parents:
|
|
173
|
+
return ResolvedPath(None, False, escapes=True)
|
|
174
|
+
|
|
175
|
+
if not candidate.exists():
|
|
176
|
+
return ResolvedPath(None, False, escapes=False)
|
|
177
|
+
|
|
178
|
+
return ResolvedPath(candidate, candidate.is_dir(), escapes=False)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def find_index_file(directory: Path) -> Path | None:
|
|
182
|
+
"""
|
|
183
|
+
Return the first existing index file inside *directory*, if any.
|
|
184
|
+
|
|
185
|
+
Checks the candidate names in :data:`INDEX_FILENAMES` in order and returns
|
|
186
|
+
the first one that exists as a regular file.
|
|
187
|
+
|
|
188
|
+
:param directory: The directory to search for an index file.
|
|
189
|
+
:return: The path to the first index file found, or ``None`` if none exist.
|
|
190
|
+
"""
|
|
191
|
+
for name in INDEX_FILENAMES:
|
|
192
|
+
candidate = directory / name
|
|
193
|
+
if candidate.is_file():
|
|
194
|
+
return candidate
|
|
195
|
+
return None
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en" data-theme="{{ theme }}">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>{% block title %}Index of {{ url_path }}{% endblock %}</title>
|
|
7
|
+
<style>
|
|
8
|
+
{% include theme ~ "/partials/styles.css.jinja2" %}
|
|
9
|
+
</style>
|
|
10
|
+
</head>
|
|
11
|
+
<body>
|
|
12
|
+
<div class="wrap">
|
|
13
|
+
{% block content %}{% endblock %}
|
|
14
|
+
</div>
|
|
15
|
+
{% block scripts %}{% endblock %}
|
|
16
|
+
</body>
|
|
17
|
+
</html>
|