xitzin 0.3.0__tar.gz → 0.5.0__tar.gz
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.
- {xitzin-0.3.0 → xitzin-0.5.0}/PKG-INFO +4 -4
- {xitzin-0.3.0 → xitzin-0.5.0}/pyproject.toml +1 -1
- {xitzin-0.3.0 → xitzin-0.5.0}/src/xitzin/__init__.py +5 -1
- {xitzin-0.3.0 → xitzin-0.5.0}/src/xitzin/application.py +215 -50
- {xitzin-0.3.0 → xitzin-0.5.0}/src/xitzin/auth.py +28 -2
- {xitzin-0.3.0 → xitzin-0.5.0}/src/xitzin/cgi.py +21 -10
- {xitzin-0.3.0 → xitzin-0.5.0}/src/xitzin/middleware.py +188 -2
- {xitzin-0.3.0 → xitzin-0.5.0}/src/xitzin/requests.py +103 -0
- {xitzin-0.3.0 → xitzin-0.5.0}/src/xitzin/responses.py +2 -3
- {xitzin-0.3.0 → xitzin-0.5.0}/src/xitzin/routing.py +165 -0
- {xitzin-0.3.0 → xitzin-0.5.0}/src/xitzin/scgi.py +1 -1
- {xitzin-0.3.0 → xitzin-0.5.0}/src/xitzin/testing.py +111 -1
- {xitzin-0.3.0 → xitzin-0.5.0}/README.md +0 -0
- {xitzin-0.3.0 → xitzin-0.5.0}/src/xitzin/exceptions.py +0 -0
- {xitzin-0.3.0 → xitzin-0.5.0}/src/xitzin/py.typed +0 -0
- {xitzin-0.3.0 → xitzin-0.5.0}/src/xitzin/sqlmodel.py +0 -0
- {xitzin-0.3.0 → xitzin-0.5.0}/src/xitzin/tasks.py +0 -0
- {xitzin-0.3.0 → xitzin-0.5.0}/src/xitzin/templating.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: xitzin
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
Summary: A Gemini Application Framework
|
|
5
5
|
Keywords: gemini,protocol,framework,async,geminispace
|
|
6
6
|
Author: Alan Velasco
|
|
@@ -25,11 +25,11 @@ Requires-Dist: typing-extensions>=4.15.0
|
|
|
25
25
|
Requires-Dist: sqlmodel>=0.0.22 ; extra == 'sqlmodel'
|
|
26
26
|
Requires-Dist: croniter>=1.0.0 ; extra == 'tasks'
|
|
27
27
|
Requires-Python: >=3.10
|
|
28
|
-
Project-URL: Changelog, https://xitzin.readthedocs.io/changelog/
|
|
29
|
-
Project-URL: Documentation, https://xitzin.readthedocs.io
|
|
30
28
|
Project-URL: Homepage, https://github.com/alanbato/xitzin
|
|
31
|
-
Project-URL:
|
|
29
|
+
Project-URL: Documentation, https://xitzin.readthedocs.io
|
|
32
30
|
Project-URL: Repository, https://github.com/alanbato/xitzin.git
|
|
31
|
+
Project-URL: Issues, https://github.com/alanbato/xitzin/issues
|
|
32
|
+
Project-URL: Changelog, https://xitzin.readthedocs.io/changelog/
|
|
33
33
|
Provides-Extra: sqlmodel
|
|
34
34
|
Provides-Extra: tasks
|
|
35
35
|
Description-Content-Type: text/markdown
|
|
@@ -22,6 +22,7 @@ Example:
|
|
|
22
22
|
|
|
23
23
|
from .application import Xitzin
|
|
24
24
|
from .cgi import CGIConfig, CGIHandler, CGIScript
|
|
25
|
+
from .middleware import VirtualHostMiddleware
|
|
25
26
|
from .scgi import SCGIApp, SCGIConfig, SCGIHandler
|
|
26
27
|
from .exceptions import (
|
|
27
28
|
BadRequest,
|
|
@@ -42,7 +43,7 @@ from .exceptions import (
|
|
|
42
43
|
TaskConfigurationError,
|
|
43
44
|
TemporaryFailure,
|
|
44
45
|
)
|
|
45
|
-
from .requests import Request
|
|
46
|
+
from .requests import Request, TitanRequest
|
|
46
47
|
from .responses import Input, Link, Redirect, Response
|
|
47
48
|
|
|
48
49
|
__all__ = [
|
|
@@ -50,10 +51,13 @@ __all__ = [
|
|
|
50
51
|
"Xitzin",
|
|
51
52
|
# Request/Response
|
|
52
53
|
"Request",
|
|
54
|
+
"TitanRequest",
|
|
53
55
|
"Response",
|
|
54
56
|
"Input",
|
|
55
57
|
"Redirect",
|
|
56
58
|
"Link",
|
|
59
|
+
# Middleware
|
|
60
|
+
"VirtualHostMiddleware",
|
|
57
61
|
# CGI support
|
|
58
62
|
"CGIConfig",
|
|
59
63
|
"CGIHandler",
|
|
@@ -11,13 +11,19 @@ from pathlib import Path
|
|
|
11
11
|
from typing import TYPE_CHECKING, Any, Callable
|
|
12
12
|
|
|
13
13
|
from nauyaca.protocol.request import GeminiRequest
|
|
14
|
+
from nauyaca.protocol.request import TitanRequest as NauyacaTitanRequest
|
|
14
15
|
from nauyaca.protocol.response import GeminiResponse
|
|
15
16
|
from nauyaca.protocol.status import StatusCode
|
|
16
17
|
|
|
17
|
-
from .exceptions import
|
|
18
|
-
|
|
18
|
+
from .exceptions import (
|
|
19
|
+
CertificateRequired,
|
|
20
|
+
GeminiException,
|
|
21
|
+
NotFound,
|
|
22
|
+
TaskConfigurationError,
|
|
23
|
+
)
|
|
24
|
+
from .requests import Request, TitanRequest
|
|
19
25
|
from .responses import Input, Redirect, convert_response
|
|
20
|
-
from .routing import MountedRoute, Route, Router
|
|
26
|
+
from .routing import MountedRoute, Route, Router, TitanRoute
|
|
21
27
|
|
|
22
28
|
if TYPE_CHECKING:
|
|
23
29
|
from .tasks import BackgroundTask
|
|
@@ -227,6 +233,45 @@ class Xitzin:
|
|
|
227
233
|
|
|
228
234
|
return decorator
|
|
229
235
|
|
|
236
|
+
def titan(
|
|
237
|
+
self,
|
|
238
|
+
path: str,
|
|
239
|
+
*,
|
|
240
|
+
name: str | None = None,
|
|
241
|
+
auth_tokens: list[str] | None = None,
|
|
242
|
+
) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
|
|
243
|
+
"""Register a Titan upload handler.
|
|
244
|
+
|
|
245
|
+
Titan is the upload companion protocol to Gemini. This decorator
|
|
246
|
+
registers a handler for Titan upload requests.
|
|
247
|
+
|
|
248
|
+
Args:
|
|
249
|
+
path: URL path pattern (e.g., "/upload/{filename}").
|
|
250
|
+
name: Optional route name. Defaults to function name.
|
|
251
|
+
auth_tokens: List of valid authentication tokens. If provided,
|
|
252
|
+
requests without a valid token are rejected with status 60.
|
|
253
|
+
|
|
254
|
+
Returns:
|
|
255
|
+
Decorator function.
|
|
256
|
+
|
|
257
|
+
Example:
|
|
258
|
+
@app.titan("/upload/{filename}", auth_tokens=["secret123"])
|
|
259
|
+
def upload(request: TitanRequest, content: bytes,
|
|
260
|
+
mime_type: str, token: str | None, filename: str):
|
|
261
|
+
if request.is_delete():
|
|
262
|
+
Path(f"./uploads/{filename}").unlink()
|
|
263
|
+
return "# Deleted"
|
|
264
|
+
Path(f"./uploads/{filename}").write_bytes(content)
|
|
265
|
+
return "# Upload successful"
|
|
266
|
+
"""
|
|
267
|
+
|
|
268
|
+
def decorator(handler: Callable[..., Any]) -> Callable[..., Any]:
|
|
269
|
+
route = TitanRoute(path, handler, name=name, auth_tokens=auth_tokens)
|
|
270
|
+
self._router.add_titan_route(route)
|
|
271
|
+
return handler
|
|
272
|
+
|
|
273
|
+
return decorator
|
|
274
|
+
|
|
230
275
|
def mount(
|
|
231
276
|
self,
|
|
232
277
|
path: str,
|
|
@@ -354,6 +399,69 @@ class Xitzin:
|
|
|
354
399
|
|
|
355
400
|
self.mount(path, handler, name=name)
|
|
356
401
|
|
|
402
|
+
def vhost(
|
|
403
|
+
self,
|
|
404
|
+
hosts: dict[str, "Xitzin"],
|
|
405
|
+
*,
|
|
406
|
+
default_app: "Xitzin | None" = None,
|
|
407
|
+
fallback_status: int = 53,
|
|
408
|
+
fallback_handler: Callable[[Request], Any] | None = None,
|
|
409
|
+
) -> None:
|
|
410
|
+
"""Configure virtual hosting for this application.
|
|
411
|
+
|
|
412
|
+
This is a convenience method that creates and registers VirtualHostMiddleware.
|
|
413
|
+
The middleware routes requests to different apps based on hostname.
|
|
414
|
+
|
|
415
|
+
Args:
|
|
416
|
+
hosts: Mapping of hostname patterns to Xitzin apps.
|
|
417
|
+
Patterns can be exact ("example.com") or wildcards ("*.example.com").
|
|
418
|
+
Exact matches are checked first, then wildcards in definition order.
|
|
419
|
+
default_app: Default app when no pattern matches.
|
|
420
|
+
fallback_status: Status code for unmatched hosts (default: 53).
|
|
421
|
+
Common values: 53 (Proxy Refused), 51 (Not Found), 59 (Bad Request).
|
|
422
|
+
fallback_handler: Custom handler for unmatched hosts.
|
|
423
|
+
Takes precedence over default_app and fallback_status.
|
|
424
|
+
|
|
425
|
+
Example:
|
|
426
|
+
main_app = Xitzin(title="Main")
|
|
427
|
+
blog_app = Xitzin(title="Blog")
|
|
428
|
+
api_app = Xitzin(title="API")
|
|
429
|
+
|
|
430
|
+
@blog_app.gemini("/")
|
|
431
|
+
def blog_home(request: Request):
|
|
432
|
+
return "# Blog Home"
|
|
433
|
+
|
|
434
|
+
@api_app.gemini("/")
|
|
435
|
+
def api_home(request: Request):
|
|
436
|
+
return "# API Home"
|
|
437
|
+
|
|
438
|
+
@main_app.gemini("/")
|
|
439
|
+
def main_home(request: Request):
|
|
440
|
+
return "# Main Home"
|
|
441
|
+
|
|
442
|
+
# Configure as gateway
|
|
443
|
+
main_app.vhost({
|
|
444
|
+
"blog.example.com": blog_app,
|
|
445
|
+
"*.api.example.com": api_app,
|
|
446
|
+
}, default_app=main_app)
|
|
447
|
+
|
|
448
|
+
main_app.run()
|
|
449
|
+
"""
|
|
450
|
+
from .middleware import VirtualHostMiddleware
|
|
451
|
+
|
|
452
|
+
vhost_mw = VirtualHostMiddleware(
|
|
453
|
+
hosts,
|
|
454
|
+
default_app=default_app,
|
|
455
|
+
fallback_status=fallback_status,
|
|
456
|
+
fallback_handler=fallback_handler,
|
|
457
|
+
)
|
|
458
|
+
|
|
459
|
+
@self.middleware
|
|
460
|
+
async def virtual_host_dispatcher(
|
|
461
|
+
request: Request, call_next: Callable[..., Any]
|
|
462
|
+
) -> GeminiResponse:
|
|
463
|
+
return await vhost_mw(request, call_next)
|
|
464
|
+
|
|
357
465
|
def on_startup(self, handler: Callable[[], Any]) -> Callable[[], Any]:
|
|
358
466
|
"""Register a startup event handler.
|
|
359
467
|
|
|
@@ -514,73 +622,111 @@ class Xitzin:
|
|
|
514
622
|
"""
|
|
515
623
|
request = Request(raw_request, self)
|
|
516
624
|
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
async def call_mounted_handler(req: Request) -> GeminiResponse:
|
|
625
|
+
# Build middleware chain around the entire routing logic
|
|
626
|
+
async def route_and_handle(req: Request) -> GeminiResponse:
|
|
627
|
+
try:
|
|
628
|
+
# Check mounted routes first
|
|
629
|
+
mount_match = self._router.match_mount(req.path)
|
|
630
|
+
if mount_match is not None:
|
|
631
|
+
mounted_route, path_info = mount_match
|
|
525
632
|
result = await mounted_route.call_handler(req, path_info)
|
|
526
633
|
return convert_response(result, req)
|
|
527
634
|
|
|
528
|
-
#
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
635
|
+
# Match regular route
|
|
636
|
+
match = self._router.match(req.path)
|
|
637
|
+
if match is None:
|
|
638
|
+
raise NotFound(f"No route matches: {req.path}")
|
|
639
|
+
|
|
640
|
+
route, params = match
|
|
641
|
+
|
|
642
|
+
# Handle input flow
|
|
643
|
+
if route.input_prompt and not req.query:
|
|
644
|
+
return Input(
|
|
645
|
+
route.input_prompt, route.sensitive_input
|
|
646
|
+
).to_gemini_response()
|
|
647
|
+
|
|
648
|
+
# Add query to params for input routes
|
|
649
|
+
if route.input_prompt and req.query:
|
|
650
|
+
params["query"] = req.query
|
|
651
|
+
|
|
652
|
+
# Call the handler
|
|
653
|
+
result = await route.call_handler(req, params)
|
|
654
|
+
return convert_response(result, req)
|
|
655
|
+
|
|
656
|
+
except GeminiException as e:
|
|
657
|
+
return GeminiResponse(status=e.status_code, meta=e.message)
|
|
658
|
+
except Exception:
|
|
659
|
+
# Log the error and return a generic failure
|
|
660
|
+
import traceback
|
|
661
|
+
|
|
662
|
+
traceback.print_exc()
|
|
663
|
+
return GeminiResponse(
|
|
664
|
+
status=StatusCode.TEMPORARY_FAILURE,
|
|
665
|
+
meta="Internal server error",
|
|
666
|
+
)
|
|
667
|
+
|
|
668
|
+
# Apply middleware around the entire routing logic
|
|
669
|
+
# This allows middleware to intercept requests before routing
|
|
670
|
+
handler = route_and_handle
|
|
671
|
+
for mw in reversed(self._middleware):
|
|
672
|
+
handler = self._wrap_middleware(mw, handler)
|
|
673
|
+
|
|
674
|
+
return await handler(request)
|
|
532
675
|
|
|
533
|
-
|
|
676
|
+
async def _handle_titan_request(
|
|
677
|
+
self, raw_request: NauyacaTitanRequest
|
|
678
|
+
) -> GeminiResponse:
|
|
679
|
+
"""Handle an incoming Titan upload request.
|
|
534
680
|
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
raise NotFound(f"No route matches: {request.path}")
|
|
681
|
+
This is the Titan request processing logic, separate from Gemini.
|
|
682
|
+
"""
|
|
683
|
+
request = TitanRequest(raw_request, self)
|
|
539
684
|
|
|
540
|
-
|
|
685
|
+
# Build middleware chain around the entire routing logic
|
|
686
|
+
async def route_and_handle(req: TitanRequest) -> GeminiResponse:
|
|
687
|
+
try:
|
|
688
|
+
# Match Titan route
|
|
689
|
+
match = self._router.match_titan(req.path)
|
|
690
|
+
if match is None:
|
|
691
|
+
raise NotFound(f"No Titan route matches: {req.path}")
|
|
541
692
|
|
|
542
|
-
|
|
543
|
-
if route.input_prompt and not request.query:
|
|
544
|
-
return Input(
|
|
545
|
-
route.input_prompt, route.sensitive_input
|
|
546
|
-
).to_gemini_response()
|
|
693
|
+
route, params = match
|
|
547
694
|
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
695
|
+
# Validate auth token if required
|
|
696
|
+
if route.auth_tokens is not None:
|
|
697
|
+
if not req.token or req.token not in route.auth_tokens:
|
|
698
|
+
raise CertificateRequired("Valid authentication token required")
|
|
551
699
|
|
|
552
|
-
|
|
553
|
-
async def call_handler(req: Request) -> GeminiResponse:
|
|
700
|
+
# Call the handler
|
|
554
701
|
result = await route.call_handler(req, params)
|
|
555
702
|
return convert_response(result, req)
|
|
556
703
|
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
704
|
+
except GeminiException as e:
|
|
705
|
+
return GeminiResponse(status=e.status_code, meta=e.message)
|
|
706
|
+
except Exception:
|
|
707
|
+
import traceback
|
|
561
708
|
|
|
562
|
-
|
|
709
|
+
traceback.print_exc()
|
|
710
|
+
return GeminiResponse(
|
|
711
|
+
status=StatusCode.TEMPORARY_FAILURE,
|
|
712
|
+
meta="Internal server error",
|
|
713
|
+
)
|
|
563
714
|
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
import traceback
|
|
715
|
+
# Apply middleware around the entire routing logic
|
|
716
|
+
handler = route_and_handle
|
|
717
|
+
for mw in reversed(self._middleware):
|
|
718
|
+
handler = self._wrap_middleware(mw, handler)
|
|
569
719
|
|
|
570
|
-
|
|
571
|
-
return GeminiResponse(
|
|
572
|
-
status=StatusCode.TEMPORARY_FAILURE,
|
|
573
|
-
meta=f"Internal error: {type(e).__name__}",
|
|
574
|
-
)
|
|
720
|
+
return await handler(request)
|
|
575
721
|
|
|
576
722
|
def _wrap_middleware(
|
|
577
723
|
self,
|
|
578
724
|
middleware: Callable[..., Any],
|
|
579
|
-
next_handler: Callable[
|
|
580
|
-
) -> Callable[
|
|
725
|
+
next_handler: Callable[..., Any],
|
|
726
|
+
) -> Callable[..., Any]:
|
|
581
727
|
"""Wrap a handler with middleware."""
|
|
582
728
|
|
|
583
|
-
async def wrapped(request:
|
|
729
|
+
async def wrapped(request: Any) -> GeminiResponse:
|
|
584
730
|
if asyncio.iscoroutinefunction(middleware):
|
|
585
731
|
return await middleware(request, next_handler)
|
|
586
732
|
return middleware(request, next_handler)
|
|
@@ -659,11 +805,30 @@ class Xitzin:
|
|
|
659
805
|
async def handle(request: GeminiRequest) -> GeminiResponse:
|
|
660
806
|
return await self._handle_request(request)
|
|
661
807
|
|
|
808
|
+
# Create Titan upload handler if Titan routes are registered
|
|
809
|
+
upload_handler = None
|
|
810
|
+
if self._router.has_titan_routes():
|
|
811
|
+
from nauyaca.server.handler import UploadHandler
|
|
812
|
+
|
|
813
|
+
class XitzinUploadHandler(UploadHandler):
|
|
814
|
+
"""Wrapper to route Titan uploads to Xitzin handlers."""
|
|
815
|
+
|
|
816
|
+
def __init__(self, app: "Xitzin") -> None:
|
|
817
|
+
self._app = app
|
|
818
|
+
|
|
819
|
+
async def handle_upload(
|
|
820
|
+
self, request: NauyacaTitanRequest
|
|
821
|
+
) -> GeminiResponse:
|
|
822
|
+
return await self._app._handle_titan_request(request)
|
|
823
|
+
|
|
824
|
+
upload_handler = XitzinUploadHandler(self)
|
|
825
|
+
print("[Xitzin] Titan upload support enabled")
|
|
826
|
+
|
|
662
827
|
# Use TLSServerProtocol for manual TLS handling
|
|
663
828
|
# (supports self-signed client certs)
|
|
664
829
|
def create_protocol() -> TLSServerProtocol:
|
|
665
830
|
return TLSServerProtocol(
|
|
666
|
-
lambda: GeminiServerProtocol(handle, None),
|
|
831
|
+
lambda: GeminiServerProtocol(handle, None, upload_handler),
|
|
667
832
|
ssl_context,
|
|
668
833
|
)
|
|
669
834
|
|
|
@@ -6,6 +6,7 @@ Gemini client certificate authentication.
|
|
|
6
6
|
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
|
|
9
|
+
import hmac
|
|
9
10
|
from dataclasses import dataclass
|
|
10
11
|
from functools import wraps
|
|
11
12
|
from typing import TYPE_CHECKING, Any, Callable
|
|
@@ -89,6 +90,27 @@ def require_certificate(handler: Callable[..., Any]) -> Callable[..., Any]:
|
|
|
89
90
|
return wrapper
|
|
90
91
|
|
|
91
92
|
|
|
93
|
+
def _timing_safe_fingerprint_check(fingerprint: str, allowed: list[str]) -> bool:
|
|
94
|
+
"""Check if fingerprint matches any allowed value using timing-safe comparison.
|
|
95
|
+
|
|
96
|
+
This prevents timing attacks that could reveal valid fingerprints.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
fingerprint: The fingerprint to check.
|
|
100
|
+
allowed: List of allowed fingerprints.
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
True if fingerprint matches any allowed value.
|
|
104
|
+
"""
|
|
105
|
+
# Encode once for comparison
|
|
106
|
+
fp_bytes = fingerprint.encode("utf-8")
|
|
107
|
+
|
|
108
|
+
for allowed_fp in allowed:
|
|
109
|
+
if hmac.compare_digest(fp_bytes, allowed_fp.encode("utf-8")):
|
|
110
|
+
return True
|
|
111
|
+
return False
|
|
112
|
+
|
|
113
|
+
|
|
92
114
|
def require_fingerprint(
|
|
93
115
|
*allowed_fingerprints: str,
|
|
94
116
|
) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
|
|
@@ -97,6 +119,8 @@ def require_fingerprint(
|
|
|
97
119
|
If the client certificate fingerprint is not in the allowed list,
|
|
98
120
|
returns status 61 (certificate not authorized).
|
|
99
121
|
|
|
122
|
+
Uses timing-safe comparison to prevent fingerprint enumeration attacks.
|
|
123
|
+
|
|
100
124
|
Args:
|
|
101
125
|
*allowed_fingerprints: SHA-256 fingerprints that are allowed.
|
|
102
126
|
|
|
@@ -111,7 +135,7 @@ def require_fingerprint(
|
|
|
111
135
|
def admin_panel(request: Request):
|
|
112
136
|
return "# Admin Panel"
|
|
113
137
|
"""
|
|
114
|
-
|
|
138
|
+
allowed_list = list(allowed_fingerprints)
|
|
115
139
|
|
|
116
140
|
def decorator(handler: Callable[..., Any]) -> Callable[..., Any]:
|
|
117
141
|
@wraps(handler)
|
|
@@ -119,7 +143,9 @@ def require_fingerprint(
|
|
|
119
143
|
if not request.client_cert_fingerprint:
|
|
120
144
|
raise CertificateRequired("Client certificate required")
|
|
121
145
|
|
|
122
|
-
if
|
|
146
|
+
if not _timing_safe_fingerprint_check(
|
|
147
|
+
request.client_cert_fingerprint, allowed_list
|
|
148
|
+
):
|
|
123
149
|
raise CertificateNotAuthorized("Certificate not authorized")
|
|
124
150
|
|
|
125
151
|
return handler(request, *args, **kwargs)
|
|
@@ -26,10 +26,13 @@ from dataclasses import dataclass, field
|
|
|
26
26
|
from pathlib import Path
|
|
27
27
|
from typing import TYPE_CHECKING
|
|
28
28
|
|
|
29
|
+
import structlog
|
|
29
30
|
from nauyaca.protocol.response import GeminiResponse
|
|
30
31
|
|
|
31
32
|
from .exceptions import BadRequest, CGIError, NotFound
|
|
32
33
|
|
|
34
|
+
logger = structlog.get_logger("xitzin.cgi")
|
|
35
|
+
|
|
33
36
|
if TYPE_CHECKING:
|
|
34
37
|
from .requests import Request
|
|
35
38
|
|
|
@@ -56,7 +59,7 @@ class CGIConfig:
|
|
|
56
59
|
max_header_size: int = 8192
|
|
57
60
|
streaming: bool = False
|
|
58
61
|
check_execute_permission: bool = True
|
|
59
|
-
inherit_environment: bool =
|
|
62
|
+
inherit_environment: bool = False
|
|
60
63
|
app_state_keys: list[str] = field(default_factory=list)
|
|
61
64
|
|
|
62
65
|
|
|
@@ -379,10 +382,14 @@ class CGIHandler:
|
|
|
379
382
|
|
|
380
383
|
# Check exit code
|
|
381
384
|
if process.returncode != 0:
|
|
382
|
-
|
|
383
|
-
if
|
|
384
|
-
|
|
385
|
-
|
|
385
|
+
# Log stderr server-side but don't expose to client
|
|
386
|
+
if stderr:
|
|
387
|
+
error_detail = stderr.decode("utf-8", errors="replace")[:500]
|
|
388
|
+
logger.error(
|
|
389
|
+
"cgi_script_failed",
|
|
390
|
+
script=str(script_path),
|
|
391
|
+
returncode=process.returncode,
|
|
392
|
+
stderr=error_detail,
|
|
386
393
|
)
|
|
387
394
|
raise CGIError(f"CGI script exited with code {process.returncode}")
|
|
388
395
|
|
|
@@ -420,7 +427,7 @@ class CGIScript:
|
|
|
420
427
|
*,
|
|
421
428
|
timeout: float = 30.0,
|
|
422
429
|
check_execute_permission: bool = True,
|
|
423
|
-
inherit_environment: bool =
|
|
430
|
+
inherit_environment: bool = False,
|
|
424
431
|
app_state_keys: list[str] | None = None,
|
|
425
432
|
) -> None:
|
|
426
433
|
"""Create a single-script CGI handler.
|
|
@@ -530,10 +537,14 @@ class CGIScript:
|
|
|
530
537
|
) from None
|
|
531
538
|
|
|
532
539
|
if process.returncode != 0:
|
|
533
|
-
|
|
534
|
-
if
|
|
535
|
-
|
|
536
|
-
|
|
540
|
+
# Log stderr server-side but don't expose to client
|
|
541
|
+
if stderr:
|
|
542
|
+
error_detail = stderr.decode("utf-8", errors="replace")[:500]
|
|
543
|
+
logger.error(
|
|
544
|
+
"cgi_script_failed",
|
|
545
|
+
script=str(self.script_path),
|
|
546
|
+
returncode=process.returncode,
|
|
547
|
+
stderr=error_detail,
|
|
537
548
|
)
|
|
538
549
|
raise CGIError(f"CGI script exited with code {process.returncode}")
|
|
539
550
|
|
|
@@ -7,6 +7,7 @@ and modify responses before they are sent to clients.
|
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
|
|
9
9
|
import asyncio
|
|
10
|
+
import re
|
|
10
11
|
import time
|
|
11
12
|
from abc import ABC
|
|
12
13
|
from collections import OrderedDict
|
|
@@ -18,6 +19,7 @@ from nauyaca.protocol.response import GeminiResponse
|
|
|
18
19
|
from nauyaca.protocol.status import StatusCode
|
|
19
20
|
|
|
20
21
|
if TYPE_CHECKING:
|
|
22
|
+
from .application import Xitzin
|
|
21
23
|
from .requests import Request
|
|
22
24
|
|
|
23
25
|
# Type alias for middleware call_next function
|
|
@@ -202,7 +204,9 @@ class RateLimitMiddleware(BaseMiddleware):
|
|
|
202
204
|
"""Get a unique identifier for the client."""
|
|
203
205
|
if request.client_cert_fingerprint:
|
|
204
206
|
return f"cert:{request.client_cert_fingerprint}"
|
|
205
|
-
#
|
|
207
|
+
# Use IP address when available for anonymous clients
|
|
208
|
+
if request.remote_addr:
|
|
209
|
+
return f"ip:{request.remote_addr}"
|
|
206
210
|
return "unknown"
|
|
207
211
|
|
|
208
212
|
def _is_rate_limited(self, client_id: str) -> bool:
|
|
@@ -309,7 +313,7 @@ class UserSessionMiddleware(BaseMiddleware):
|
|
|
309
313
|
return self._async_cache[fingerprint]
|
|
310
314
|
|
|
311
315
|
self._cache_misses += 1
|
|
312
|
-
user = await self._user_loader(fingerprint)
|
|
316
|
+
user = await self._user_loader(fingerprint)
|
|
313
317
|
|
|
314
318
|
# Add to cache
|
|
315
319
|
self._async_cache[fingerprint] = user
|
|
@@ -380,3 +384,185 @@ class UserSessionMiddleware(BaseMiddleware):
|
|
|
380
384
|
currsize=len(self._async_cache),
|
|
381
385
|
)
|
|
382
386
|
return self._sync_cached_loader.cache_info()
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
class VirtualHostMiddleware(BaseMiddleware):
|
|
390
|
+
"""Middleware for hostname-based virtual hosting.
|
|
391
|
+
|
|
392
|
+
Routes requests to different Xitzin applications based on the hostname
|
|
393
|
+
in the request URL. Supports exact hostname matches and wildcard patterns.
|
|
394
|
+
|
|
395
|
+
Example:
|
|
396
|
+
from xitzin import Xitzin
|
|
397
|
+
from xitzin.middleware import VirtualHostMiddleware
|
|
398
|
+
|
|
399
|
+
blog_app = Xitzin(title="Blog")
|
|
400
|
+
api_app = Xitzin(title="API")
|
|
401
|
+
main_app = Xitzin(title="Gateway")
|
|
402
|
+
|
|
403
|
+
@blog_app.gemini("/")
|
|
404
|
+
def blog_home(request):
|
|
405
|
+
return "# Blog Home"
|
|
406
|
+
|
|
407
|
+
@api_app.gemini("/")
|
|
408
|
+
def api_home(request):
|
|
409
|
+
return "# API Home"
|
|
410
|
+
|
|
411
|
+
@main_app.gemini("/")
|
|
412
|
+
def main_home(request):
|
|
413
|
+
return "# Main Home"
|
|
414
|
+
|
|
415
|
+
# Create virtual host middleware
|
|
416
|
+
vhost_mw = VirtualHostMiddleware({
|
|
417
|
+
"blog.example.com": blog_app,
|
|
418
|
+
"*.api.example.com": api_app,
|
|
419
|
+
}, default_app=main_app)
|
|
420
|
+
|
|
421
|
+
@main_app.middleware
|
|
422
|
+
async def vhost(request, call_next):
|
|
423
|
+
return await vhost_mw(request, call_next)
|
|
424
|
+
|
|
425
|
+
main_app.run()
|
|
426
|
+
"""
|
|
427
|
+
|
|
428
|
+
def __init__(
|
|
429
|
+
self,
|
|
430
|
+
hosts: dict[str, "Xitzin"],
|
|
431
|
+
*,
|
|
432
|
+
default_app: "Xitzin | None" = None,
|
|
433
|
+
fallback_status: int = 53,
|
|
434
|
+
fallback_handler: (
|
|
435
|
+
Callable[["Request"], Any] | Callable[["Request"], Awaitable[Any]] | None
|
|
436
|
+
) = None,
|
|
437
|
+
) -> None:
|
|
438
|
+
"""Create virtual host middleware.
|
|
439
|
+
|
|
440
|
+
Args:
|
|
441
|
+
hosts: Mapping of hostname patterns to Xitzin apps.
|
|
442
|
+
Keys can be exact hostnames ("example.com") or wildcard patterns
|
|
443
|
+
("*.example.com"). Exact matches are checked first, then wildcards
|
|
444
|
+
in definition order.
|
|
445
|
+
default_app: Default app to use when no pattern matches.
|
|
446
|
+
Takes precedence over fallback_status.
|
|
447
|
+
fallback_status: Status code to return when no match and no default_app.
|
|
448
|
+
Defaults to 53 (Proxy Request Refused). Common values:
|
|
449
|
+
- 53: Proxy Request Refused (default)
|
|
450
|
+
- 51: Not Found
|
|
451
|
+
- 59: Bad Request
|
|
452
|
+
fallback_handler: Custom handler function for unmatched hosts.
|
|
453
|
+
Receives the request and must return a response. Takes precedence
|
|
454
|
+
over both default_app and fallback_status. Can be sync or async.
|
|
455
|
+
"""
|
|
456
|
+
self._default_app = default_app
|
|
457
|
+
self._fallback_status = fallback_status
|
|
458
|
+
self._fallback_handler = fallback_handler
|
|
459
|
+
self._is_fallback_async = (
|
|
460
|
+
iscoroutinefunction(fallback_handler) if fallback_handler else False
|
|
461
|
+
)
|
|
462
|
+
|
|
463
|
+
# Separate exact and wildcard patterns for efficiency
|
|
464
|
+
self._exact_hosts: dict[str, "Xitzin"] = {}
|
|
465
|
+
self._wildcard_patterns: list[tuple[re.Pattern[str], "Xitzin"]] = []
|
|
466
|
+
|
|
467
|
+
for pattern, app in hosts.items():
|
|
468
|
+
if pattern.startswith("*."):
|
|
469
|
+
compiled = self._compile_wildcard_pattern(pattern)
|
|
470
|
+
if compiled:
|
|
471
|
+
self._wildcard_patterns.append((compiled, app))
|
|
472
|
+
else:
|
|
473
|
+
self._exact_hosts[pattern.lower()] = app
|
|
474
|
+
|
|
475
|
+
def _compile_wildcard_pattern(self, pattern: str) -> re.Pattern[str] | None:
|
|
476
|
+
"""Convert wildcard pattern to regex.
|
|
477
|
+
|
|
478
|
+
Supports patterns like:
|
|
479
|
+
- *.example.com -> matches "blog.example.com", "api.example.com"
|
|
480
|
+
|
|
481
|
+
Args:
|
|
482
|
+
pattern: Wildcard pattern string starting with "*.".
|
|
483
|
+
|
|
484
|
+
Returns:
|
|
485
|
+
Compiled regex pattern, or None if invalid.
|
|
486
|
+
"""
|
|
487
|
+
if not pattern.startswith("*."):
|
|
488
|
+
return None
|
|
489
|
+
|
|
490
|
+
# Extract domain part after "*."
|
|
491
|
+
domain = pattern[2:]
|
|
492
|
+
# Escape special regex chars in domain
|
|
493
|
+
domain_escaped = re.escape(domain)
|
|
494
|
+
# Replace the wildcard with a pattern that matches any non-dot chars
|
|
495
|
+
regex = f"^[^.]+\\.{domain_escaped}$"
|
|
496
|
+
|
|
497
|
+
return re.compile(regex, re.IGNORECASE)
|
|
498
|
+
|
|
499
|
+
def _match_hostname(self, hostname: str) -> "Xitzin | None":
|
|
500
|
+
"""Find the app for a given hostname.
|
|
501
|
+
|
|
502
|
+
Checks exact matches first, then wildcard patterns in order.
|
|
503
|
+
|
|
504
|
+
Args:
|
|
505
|
+
hostname: The hostname from the request.
|
|
506
|
+
|
|
507
|
+
Returns:
|
|
508
|
+
Matching Xitzin app, or None if no match.
|
|
509
|
+
"""
|
|
510
|
+
hostname_lower = hostname.lower()
|
|
511
|
+
|
|
512
|
+
# Try exact match first
|
|
513
|
+
if hostname_lower in self._exact_hosts:
|
|
514
|
+
return self._exact_hosts[hostname_lower]
|
|
515
|
+
|
|
516
|
+
# Try wildcard patterns
|
|
517
|
+
for pattern, app in self._wildcard_patterns:
|
|
518
|
+
if pattern.match(hostname_lower):
|
|
519
|
+
return app
|
|
520
|
+
|
|
521
|
+
return None
|
|
522
|
+
|
|
523
|
+
async def before_request(
|
|
524
|
+
self, request: "Request"
|
|
525
|
+
) -> "Request | GeminiResponse | None":
|
|
526
|
+
"""Route request to appropriate app based on hostname."""
|
|
527
|
+
from .requests import TitanRequest
|
|
528
|
+
from .responses import convert_response
|
|
529
|
+
|
|
530
|
+
hostname = request.hostname
|
|
531
|
+
|
|
532
|
+
# Find matching app
|
|
533
|
+
app = self._match_hostname(hostname)
|
|
534
|
+
|
|
535
|
+
# Handle no match
|
|
536
|
+
if app is None:
|
|
537
|
+
# Priority 1: Custom fallback handler
|
|
538
|
+
if self._fallback_handler:
|
|
539
|
+
if self._is_fallback_async:
|
|
540
|
+
result = await self._fallback_handler(request)
|
|
541
|
+
else:
|
|
542
|
+
loop = asyncio.get_running_loop()
|
|
543
|
+
result = await loop.run_in_executor(
|
|
544
|
+
None, self._fallback_handler, request
|
|
545
|
+
)
|
|
546
|
+
return convert_response(result, request)
|
|
547
|
+
|
|
548
|
+
# Priority 2: Default app
|
|
549
|
+
if self._default_app:
|
|
550
|
+
app = self._default_app
|
|
551
|
+
else:
|
|
552
|
+
# Priority 3: Fallback status
|
|
553
|
+
return GeminiResponse(
|
|
554
|
+
status=StatusCode(self._fallback_status),
|
|
555
|
+
meta="Host not configured for this server",
|
|
556
|
+
)
|
|
557
|
+
|
|
558
|
+
# If the matched app is the same as the request's app, return None
|
|
559
|
+
# to continue processing through the normal route chain (avoid recursion)
|
|
560
|
+
if request._app is not None and app is request._app:
|
|
561
|
+
return None
|
|
562
|
+
|
|
563
|
+
# Dispatch to matched app
|
|
564
|
+
if isinstance(request, TitanRequest):
|
|
565
|
+
response = await app._handle_titan_request(request._raw_request)
|
|
566
|
+
else:
|
|
567
|
+
response = await app._handle_request(request._raw_request)
|
|
568
|
+
return response
|
|
@@ -9,6 +9,7 @@ from typing import TYPE_CHECKING, Any
|
|
|
9
9
|
from urllib.parse import unquote_plus
|
|
10
10
|
|
|
11
11
|
from nauyaca.protocol.request import GeminiRequest
|
|
12
|
+
from nauyaca.protocol.request import TitanRequest as NauyacaTitanRequest
|
|
12
13
|
|
|
13
14
|
if TYPE_CHECKING:
|
|
14
15
|
from cryptography.x509 import Certificate
|
|
@@ -148,3 +149,105 @@ class Request:
|
|
|
148
149
|
|
|
149
150
|
def __repr__(self) -> str:
|
|
150
151
|
return f"Request({self._raw_request.raw_url!r})"
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class TitanRequest:
|
|
155
|
+
"""Wraps a Nauyaca TitanRequest for Titan upload handlers.
|
|
156
|
+
|
|
157
|
+
Handlers receive this object as their first argument for @app.titan routes.
|
|
158
|
+
|
|
159
|
+
Example:
|
|
160
|
+
@app.titan("/upload/{filename}", auth_tokens=["secret"])
|
|
161
|
+
def upload(request: TitanRequest, content: bytes,
|
|
162
|
+
mime_type: str, token: str | None, filename: str):
|
|
163
|
+
if request.is_delete():
|
|
164
|
+
return "# Deleted"
|
|
165
|
+
Path(f"./uploads/{filename}").write_bytes(content)
|
|
166
|
+
return "# Upload successful"
|
|
167
|
+
|
|
168
|
+
Attributes:
|
|
169
|
+
app: The Xitzin application instance.
|
|
170
|
+
state: Arbitrary state storage for this request.
|
|
171
|
+
path: The URL path component.
|
|
172
|
+
content: The uploaded content bytes.
|
|
173
|
+
mime_type: Content MIME type.
|
|
174
|
+
token: Authentication token (if provided).
|
|
175
|
+
size: Content size in bytes.
|
|
176
|
+
"""
|
|
177
|
+
|
|
178
|
+
def __init__(
|
|
179
|
+
self, raw_request: NauyacaTitanRequest, app: Xitzin | None = None
|
|
180
|
+
) -> None:
|
|
181
|
+
self._raw_request = raw_request
|
|
182
|
+
self._app = app
|
|
183
|
+
self._state = RequestState()
|
|
184
|
+
|
|
185
|
+
@property
|
|
186
|
+
def app(self) -> Xitzin:
|
|
187
|
+
"""The Xitzin application handling this request."""
|
|
188
|
+
if self._app is None:
|
|
189
|
+
msg = "Request is not bound to an application"
|
|
190
|
+
raise RuntimeError(msg)
|
|
191
|
+
return self._app
|
|
192
|
+
|
|
193
|
+
@property
|
|
194
|
+
def state(self) -> RequestState:
|
|
195
|
+
"""Arbitrary state storage for this request."""
|
|
196
|
+
return self._state
|
|
197
|
+
|
|
198
|
+
@property
|
|
199
|
+
def path(self) -> str:
|
|
200
|
+
"""The URL path component."""
|
|
201
|
+
return self._raw_request.path
|
|
202
|
+
|
|
203
|
+
@property
|
|
204
|
+
def content(self) -> bytes:
|
|
205
|
+
"""The uploaded content bytes."""
|
|
206
|
+
return self._raw_request.content
|
|
207
|
+
|
|
208
|
+
@property
|
|
209
|
+
def mime_type(self) -> str:
|
|
210
|
+
"""Content MIME type."""
|
|
211
|
+
return self._raw_request.mime_type
|
|
212
|
+
|
|
213
|
+
@property
|
|
214
|
+
def token(self) -> str | None:
|
|
215
|
+
"""Authentication token (if provided)."""
|
|
216
|
+
return self._raw_request.token
|
|
217
|
+
|
|
218
|
+
@property
|
|
219
|
+
def size(self) -> int:
|
|
220
|
+
"""Content size in bytes."""
|
|
221
|
+
return self._raw_request.size
|
|
222
|
+
|
|
223
|
+
def is_delete(self) -> bool:
|
|
224
|
+
"""Check if this is a delete request (zero-byte upload)."""
|
|
225
|
+
return self._raw_request.is_delete()
|
|
226
|
+
|
|
227
|
+
@property
|
|
228
|
+
def hostname(self) -> str:
|
|
229
|
+
"""The server hostname from the URL."""
|
|
230
|
+
return self._raw_request.hostname
|
|
231
|
+
|
|
232
|
+
@property
|
|
233
|
+
def port(self) -> int:
|
|
234
|
+
"""The server port from the URL."""
|
|
235
|
+
return self._raw_request.port
|
|
236
|
+
|
|
237
|
+
@property
|
|
238
|
+
def client_cert(self) -> Certificate | None:
|
|
239
|
+
"""The client's TLS certificate, if provided."""
|
|
240
|
+
return self._raw_request.client_cert
|
|
241
|
+
|
|
242
|
+
@property
|
|
243
|
+
def client_cert_fingerprint(self) -> str | None:
|
|
244
|
+
"""SHA-256 fingerprint of the client certificate."""
|
|
245
|
+
return self._raw_request.client_cert_fingerprint
|
|
246
|
+
|
|
247
|
+
@property
|
|
248
|
+
def raw_url(self) -> str:
|
|
249
|
+
"""The original URL from the request."""
|
|
250
|
+
return self._raw_request.raw_url
|
|
251
|
+
|
|
252
|
+
def __repr__(self) -> str:
|
|
253
|
+
return f"TitanRequest({self._raw_request.raw_url!r})"
|
|
@@ -14,7 +14,6 @@ from nauyaca.protocol.status import StatusCode
|
|
|
14
14
|
|
|
15
15
|
if TYPE_CHECKING:
|
|
16
16
|
from .application import Xitzin
|
|
17
|
-
from .requests import Request
|
|
18
17
|
|
|
19
18
|
|
|
20
19
|
class ResponseConvertible(Protocol):
|
|
@@ -156,7 +155,7 @@ class Link:
|
|
|
156
155
|
return self.to_gemtext()
|
|
157
156
|
|
|
158
157
|
|
|
159
|
-
def convert_response(result: Any, request:
|
|
158
|
+
def convert_response(result: Any, request: Any = None) -> GeminiResponse:
|
|
160
159
|
"""Convert a handler return value to a GeminiResponse.
|
|
161
160
|
|
|
162
161
|
Handlers can return:
|
|
@@ -168,7 +167,7 @@ def convert_response(result: Any, request: Request | None = None) -> GeminiRespo
|
|
|
168
167
|
|
|
169
168
|
Args:
|
|
170
169
|
result: The return value from a handler.
|
|
171
|
-
request: The current request (for URL tracking).
|
|
170
|
+
request: The current request (Request or TitanRequest, for URL tracking).
|
|
172
171
|
|
|
173
172
|
Returns:
|
|
174
173
|
A GeminiResponse instance.
|
|
@@ -282,6 +282,143 @@ class MountedRoute:
|
|
|
282
282
|
return f"MountedRoute({self.path_prefix!r}, name={self.name!r})"
|
|
283
283
|
|
|
284
284
|
|
|
285
|
+
class TitanRoute:
|
|
286
|
+
"""Route for Titan upload handlers with integrated authentication.
|
|
287
|
+
|
|
288
|
+
Similar to Route, but designed for Titan uploads with:
|
|
289
|
+
- Token-based authentication
|
|
290
|
+
- Explicit content/mime_type/token parameters passed to handlers
|
|
291
|
+
|
|
292
|
+
Example:
|
|
293
|
+
route = TitanRoute(
|
|
294
|
+
"/upload/{filename}",
|
|
295
|
+
upload_handler,
|
|
296
|
+
auth_tokens=["secret123"]
|
|
297
|
+
)
|
|
298
|
+
"""
|
|
299
|
+
|
|
300
|
+
def __init__(
|
|
301
|
+
self,
|
|
302
|
+
path: str,
|
|
303
|
+
handler: Callable[..., Any],
|
|
304
|
+
*,
|
|
305
|
+
name: str | None = None,
|
|
306
|
+
auth_tokens: list[str] | None = None,
|
|
307
|
+
) -> None:
|
|
308
|
+
"""Create a new Titan route.
|
|
309
|
+
|
|
310
|
+
Args:
|
|
311
|
+
path: Path template with optional parameters (e.g., "/upload/{filename}").
|
|
312
|
+
handler: The handler function to call.
|
|
313
|
+
name: Route name for identification. Defaults to handler function name.
|
|
314
|
+
auth_tokens: List of valid authentication tokens. If provided,
|
|
315
|
+
requests without a valid token are rejected with status 60.
|
|
316
|
+
"""
|
|
317
|
+
self.path = path
|
|
318
|
+
self.handler = handler
|
|
319
|
+
self.name = (
|
|
320
|
+
name if name is not None else getattr(handler, "__name__", "<anonymous>")
|
|
321
|
+
)
|
|
322
|
+
self.auth_tokens = set(auth_tokens) if auth_tokens else None
|
|
323
|
+
|
|
324
|
+
self._param_pattern, self._param_names = self._compile_path(path)
|
|
325
|
+
self._type_hints = self._get_handler_type_hints(handler)
|
|
326
|
+
self._is_async = asyncio.iscoroutinefunction(handler)
|
|
327
|
+
|
|
328
|
+
def _compile_path(self, path: str) -> tuple[re.Pattern[str], list[str]]:
|
|
329
|
+
"""Convert a path template to a regex pattern.
|
|
330
|
+
|
|
331
|
+
Same logic as Route._compile_path().
|
|
332
|
+
"""
|
|
333
|
+
param_names: list[str] = []
|
|
334
|
+
|
|
335
|
+
def replace_param(match: re.Match[str]) -> str:
|
|
336
|
+
name = match.group(1)
|
|
337
|
+
param_type = match.group(2)
|
|
338
|
+
param_names.append(name)
|
|
339
|
+
|
|
340
|
+
if param_type == "path":
|
|
341
|
+
return f"(?P<{name}>.+)"
|
|
342
|
+
return f"(?P<{name}>[^/]+)"
|
|
343
|
+
|
|
344
|
+
escaped = re.escape(path)
|
|
345
|
+
escaped = escaped.replace(r"\{", "{").replace(r"\}", "}")
|
|
346
|
+
regex_path = PATH_PARAM_PATTERN.sub(replace_param, escaped)
|
|
347
|
+
|
|
348
|
+
return re.compile(f"^{regex_path}$"), param_names
|
|
349
|
+
|
|
350
|
+
def _get_handler_type_hints(self, handler: Callable[..., Any]) -> dict[str, type]:
|
|
351
|
+
"""Extract type hints from handler function.
|
|
352
|
+
|
|
353
|
+
Excludes 'request', 'content', 'mime_type', 'token', and 'return'.
|
|
354
|
+
"""
|
|
355
|
+
try:
|
|
356
|
+
hints = get_type_hints(handler)
|
|
357
|
+
# Remove non-path-parameter hints
|
|
358
|
+
hints.pop("request", None)
|
|
359
|
+
hints.pop("content", None)
|
|
360
|
+
hints.pop("mime_type", None)
|
|
361
|
+
hints.pop("token", None)
|
|
362
|
+
hints.pop("return", None)
|
|
363
|
+
return hints
|
|
364
|
+
except Exception:
|
|
365
|
+
return {}
|
|
366
|
+
|
|
367
|
+
def matches(self, path: str) -> bool:
|
|
368
|
+
"""Check if this route matches the given path."""
|
|
369
|
+
return self._param_pattern.match(path) is not None
|
|
370
|
+
|
|
371
|
+
def extract_params(self, path: str) -> dict[str, Any]:
|
|
372
|
+
"""Extract and type-convert path parameters."""
|
|
373
|
+
match = self._param_pattern.match(path)
|
|
374
|
+
if not match:
|
|
375
|
+
return {}
|
|
376
|
+
|
|
377
|
+
params: dict[str, Any] = {}
|
|
378
|
+
for name, value in match.groupdict().items():
|
|
379
|
+
target_type = self._type_hints.get(name, str)
|
|
380
|
+
try:
|
|
381
|
+
if target_type is int:
|
|
382
|
+
params[name] = int(value)
|
|
383
|
+
elif target_type is float:
|
|
384
|
+
params[name] = float(value)
|
|
385
|
+
elif target_type is bool:
|
|
386
|
+
params[name] = value.lower() in ("true", "1", "yes")
|
|
387
|
+
else:
|
|
388
|
+
params[name] = value
|
|
389
|
+
except (ValueError, TypeError):
|
|
390
|
+
params[name] = value
|
|
391
|
+
|
|
392
|
+
return params
|
|
393
|
+
|
|
394
|
+
async def call_handler(self, request: Any, params: dict[str, Any]) -> Any:
|
|
395
|
+
"""Call the handler with request and explicit Titan parameters.
|
|
396
|
+
|
|
397
|
+
Args:
|
|
398
|
+
request: TitanRequest object (typed as Any to avoid circular imports).
|
|
399
|
+
params: Path parameters extracted from the URL.
|
|
400
|
+
|
|
401
|
+
Handler receives: (request, content, mime_type, token, **path_params)
|
|
402
|
+
"""
|
|
403
|
+
# Add Titan-specific parameters
|
|
404
|
+
handler_params = {
|
|
405
|
+
"content": request.content,
|
|
406
|
+
"mime_type": request.mime_type,
|
|
407
|
+
"token": request.token,
|
|
408
|
+
**params,
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
if self._is_async:
|
|
412
|
+
return await self.handler(request, **handler_params)
|
|
413
|
+
loop = asyncio.get_event_loop()
|
|
414
|
+
return await loop.run_in_executor(
|
|
415
|
+
None, lambda: self.handler(request, **handler_params)
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
def __repr__(self) -> str:
|
|
419
|
+
return f"TitanRoute({self.path!r}, name={self.name!r})"
|
|
420
|
+
|
|
421
|
+
|
|
285
422
|
class Router:
|
|
286
423
|
"""Collection of routes with matching logic.
|
|
287
424
|
|
|
@@ -293,6 +430,7 @@ class Router:
|
|
|
293
430
|
self._routes: list[Route] = []
|
|
294
431
|
self._routes_by_name: dict[str, Route] = {}
|
|
295
432
|
self._mounted_routes: list[MountedRoute] = []
|
|
433
|
+
self._titan_routes: list[TitanRoute] = []
|
|
296
434
|
|
|
297
435
|
def add_route(self, route: Route) -> None:
|
|
298
436
|
"""Add a route to the router.
|
|
@@ -351,6 +489,33 @@ class Router:
|
|
|
351
489
|
return route, params
|
|
352
490
|
return None
|
|
353
491
|
|
|
492
|
+
def add_titan_route(self, route: TitanRoute) -> None:
|
|
493
|
+
"""Add a Titan upload route to the router.
|
|
494
|
+
|
|
495
|
+
Args:
|
|
496
|
+
route: The Titan route to add.
|
|
497
|
+
"""
|
|
498
|
+
self._titan_routes.append(route)
|
|
499
|
+
|
|
500
|
+
def match_titan(self, path: str) -> tuple[TitanRoute, dict[str, Any]] | None:
|
|
501
|
+
"""Find a matching Titan route and extract parameters.
|
|
502
|
+
|
|
503
|
+
Args:
|
|
504
|
+
path: URL path to match.
|
|
505
|
+
|
|
506
|
+
Returns:
|
|
507
|
+
Tuple of (titan_route, params) if found, None otherwise.
|
|
508
|
+
"""
|
|
509
|
+
for route in self._titan_routes:
|
|
510
|
+
if route.matches(path):
|
|
511
|
+
params = route.extract_params(path)
|
|
512
|
+
return route, params
|
|
513
|
+
return None
|
|
514
|
+
|
|
515
|
+
def has_titan_routes(self) -> bool:
|
|
516
|
+
"""Check if any Titan routes are registered."""
|
|
517
|
+
return len(self._titan_routes) > 0
|
|
518
|
+
|
|
354
519
|
def reverse(self, name: str, **params: Any) -> str:
|
|
355
520
|
"""Build URL for a named route.
|
|
356
521
|
|
|
@@ -49,7 +49,7 @@ class SCGIConfig:
|
|
|
49
49
|
timeout: float = 30.0
|
|
50
50
|
max_response_size: int | None = 1048576 # 1MB default
|
|
51
51
|
buffer_size: int = 8192
|
|
52
|
-
inherit_environment: bool =
|
|
52
|
+
inherit_environment: bool = False
|
|
53
53
|
app_state_keys: list[str] = field(default_factory=list)
|
|
54
54
|
|
|
55
55
|
|
|
@@ -13,6 +13,7 @@ from typing import TYPE_CHECKING, Generator
|
|
|
13
13
|
from urllib.parse import quote_plus
|
|
14
14
|
|
|
15
15
|
from nauyaca.protocol.request import GeminiRequest
|
|
16
|
+
from nauyaca.protocol.request import TitanRequest as NauyacaTitanRequest
|
|
16
17
|
from nauyaca.protocol.response import GeminiResponse
|
|
17
18
|
|
|
18
19
|
if TYPE_CHECKING:
|
|
@@ -153,10 +154,15 @@ class TestClient:
|
|
|
153
154
|
# Handle request through the app
|
|
154
155
|
response = self._handle_sync(request)
|
|
155
156
|
|
|
157
|
+
# Convert bytes body to str for TestResponse
|
|
158
|
+
body = response.body
|
|
159
|
+
if isinstance(body, bytes):
|
|
160
|
+
body = body.decode("utf-8")
|
|
161
|
+
|
|
156
162
|
return TestResponse(
|
|
157
163
|
status=response.status,
|
|
158
164
|
meta=response.meta,
|
|
159
|
-
body=
|
|
165
|
+
body=body,
|
|
160
166
|
)
|
|
161
167
|
|
|
162
168
|
def get_input(
|
|
@@ -210,6 +216,100 @@ class TestClient:
|
|
|
210
216
|
new_client._default_fingerprint = fingerprint
|
|
211
217
|
return new_client
|
|
212
218
|
|
|
219
|
+
def upload(
|
|
220
|
+
self,
|
|
221
|
+
path: str,
|
|
222
|
+
content: bytes | str,
|
|
223
|
+
*,
|
|
224
|
+
mime_type: str = "text/gemini",
|
|
225
|
+
token: str | None = None,
|
|
226
|
+
cert_fingerprint: str | None = None,
|
|
227
|
+
) -> TestResponse:
|
|
228
|
+
"""Make a Titan upload request.
|
|
229
|
+
|
|
230
|
+
Args:
|
|
231
|
+
path: The request path (e.g., "/upload/file.gmi").
|
|
232
|
+
content: Content to upload (str will be UTF-8 encoded).
|
|
233
|
+
mime_type: Content MIME type (default: text/gemini).
|
|
234
|
+
token: Authentication token (if required by route).
|
|
235
|
+
cert_fingerprint: Mock client certificate fingerprint.
|
|
236
|
+
|
|
237
|
+
Returns:
|
|
238
|
+
TestResponse with status, meta, and body.
|
|
239
|
+
|
|
240
|
+
Example:
|
|
241
|
+
response = client.upload(
|
|
242
|
+
"/files/test.gmi",
|
|
243
|
+
"# Hello World",
|
|
244
|
+
mime_type="text/gemini",
|
|
245
|
+
token="secret123"
|
|
246
|
+
)
|
|
247
|
+
assert response.is_success
|
|
248
|
+
"""
|
|
249
|
+
# Convert str to bytes
|
|
250
|
+
if isinstance(content, str):
|
|
251
|
+
content_bytes = content.encode("utf-8")
|
|
252
|
+
else:
|
|
253
|
+
content_bytes = content
|
|
254
|
+
|
|
255
|
+
# Build Titan URL
|
|
256
|
+
size = len(content_bytes)
|
|
257
|
+
url = f"titan://testserver{path};size={size};mime={mime_type}"
|
|
258
|
+
if token:
|
|
259
|
+
url += f";token={token}"
|
|
260
|
+
|
|
261
|
+
# Create TitanRequest
|
|
262
|
+
request = NauyacaTitanRequest.from_line(url)
|
|
263
|
+
request.content = content_bytes
|
|
264
|
+
|
|
265
|
+
# Set certificate info
|
|
266
|
+
fingerprint = cert_fingerprint or self._default_fingerprint
|
|
267
|
+
if fingerprint:
|
|
268
|
+
request.client_cert_fingerprint = fingerprint
|
|
269
|
+
|
|
270
|
+
# Handle through app
|
|
271
|
+
response = self._handle_titan_sync(request)
|
|
272
|
+
|
|
273
|
+
# Convert bytes body to str for TestResponse
|
|
274
|
+
body = response.body
|
|
275
|
+
if isinstance(body, bytes):
|
|
276
|
+
body = body.decode("utf-8")
|
|
277
|
+
|
|
278
|
+
return TestResponse(
|
|
279
|
+
status=response.status,
|
|
280
|
+
meta=response.meta,
|
|
281
|
+
body=body,
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
def delete(
|
|
285
|
+
self,
|
|
286
|
+
path: str,
|
|
287
|
+
*,
|
|
288
|
+
token: str | None = None,
|
|
289
|
+
cert_fingerprint: str | None = None,
|
|
290
|
+
) -> TestResponse:
|
|
291
|
+
"""Make a Titan delete request (zero-byte upload).
|
|
292
|
+
|
|
293
|
+
Args:
|
|
294
|
+
path: The request path to delete.
|
|
295
|
+
token: Authentication token (if required by route).
|
|
296
|
+
cert_fingerprint: Mock client certificate fingerprint.
|
|
297
|
+
|
|
298
|
+
Returns:
|
|
299
|
+
TestResponse with status, meta, and body.
|
|
300
|
+
|
|
301
|
+
Example:
|
|
302
|
+
response = client.delete("/files/old.gmi", token="secret123")
|
|
303
|
+
assert response.is_success
|
|
304
|
+
"""
|
|
305
|
+
return self.upload(
|
|
306
|
+
path,
|
|
307
|
+
b"",
|
|
308
|
+
mime_type="text/gemini",
|
|
309
|
+
token=token,
|
|
310
|
+
cert_fingerprint=cert_fingerprint,
|
|
311
|
+
)
|
|
312
|
+
|
|
213
313
|
def _handle_sync(self, request: GeminiRequest) -> GeminiResponse:
|
|
214
314
|
"""Handle a request synchronously."""
|
|
215
315
|
try:
|
|
@@ -220,6 +320,16 @@ class TestClient:
|
|
|
220
320
|
|
|
221
321
|
return loop.run_until_complete(self._app._handle_request(request))
|
|
222
322
|
|
|
323
|
+
def _handle_titan_sync(self, request: NauyacaTitanRequest) -> GeminiResponse:
|
|
324
|
+
"""Handle a Titan request synchronously."""
|
|
325
|
+
try:
|
|
326
|
+
loop = asyncio.get_event_loop()
|
|
327
|
+
except RuntimeError:
|
|
328
|
+
loop = asyncio.new_event_loop()
|
|
329
|
+
asyncio.set_event_loop(loop)
|
|
330
|
+
|
|
331
|
+
return loop.run_until_complete(self._app._handle_titan_request(request))
|
|
332
|
+
|
|
223
333
|
|
|
224
334
|
@contextmanager
|
|
225
335
|
def test_app(app: "Xitzin") -> Generator[TestClient, None, None]:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|