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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: xitzin
3
- Version: 0.3.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: Issues, https://github.com/alanbato/xitzin/issues
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "xitzin"
3
- version = "0.3.0"
3
+ version = "0.5.0"
4
4
  description = "A Gemini Application Framework"
5
5
  readme = "README.md"
6
6
  license = { text = "MIT" }
@@ -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 GeminiException, NotFound, TaskConfigurationError
18
- from .requests import Request
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
- try:
518
- # Check mounted routes first
519
- mount_match = self._router.match_mount(request.path)
520
- if mount_match is not None:
521
- mounted_route, path_info = mount_match
522
-
523
- # Build middleware chain for mounted handler
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
- # Apply middleware
529
- handler = call_mounted_handler
530
- for mw in reversed(self._middleware):
531
- handler = self._wrap_middleware(mw, handler)
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
- return await handler(request)
676
+ async def _handle_titan_request(
677
+ self, raw_request: NauyacaTitanRequest
678
+ ) -> GeminiResponse:
679
+ """Handle an incoming Titan upload request.
534
680
 
535
- # Match regular route
536
- match = self._router.match(request.path)
537
- if match is None:
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
- route, params = match
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
- # Handle input flow
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
- # Add query to params for input routes
549
- if route.input_prompt and request.query:
550
- params["query"] = request.query
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
- # Build middleware chain
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
- # Apply middleware (in reverse order so first registered runs first)
558
- handler = call_handler
559
- for mw in reversed(self._middleware):
560
- handler = self._wrap_middleware(mw, handler)
704
+ except GeminiException as e:
705
+ return GeminiResponse(status=e.status_code, meta=e.message)
706
+ except Exception:
707
+ import traceback
561
708
 
562
- return await handler(request)
709
+ traceback.print_exc()
710
+ return GeminiResponse(
711
+ status=StatusCode.TEMPORARY_FAILURE,
712
+ meta="Internal server error",
713
+ )
563
714
 
564
- except GeminiException as e:
565
- return GeminiResponse(status=e.status_code, meta=e.message)
566
- except Exception as e:
567
- # Log the error and return a generic failure
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
- traceback.print_exc()
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[[Request], Any],
580
- ) -> Callable[[Request], Any]:
725
+ next_handler: Callable[..., Any],
726
+ ) -> Callable[..., Any]:
581
727
  """Wrap a handler with middleware."""
582
728
 
583
- async def wrapped(request: Request) -> GeminiResponse:
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), # type: ignore[arg-type]
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
- allowed_set = set(allowed_fingerprints)
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 request.client_cert_fingerprint not in allowed_set:
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 = True
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
- error_msg = stderr.decode("utf-8", errors="replace")[:200]
383
- if error_msg:
384
- raise CGIError(
385
- f"CGI script exited with code {process.returncode}: {error_msg}"
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 = True,
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
- error_msg = stderr.decode("utf-8", errors="replace")[:200]
534
- if error_msg:
535
- raise CGIError(
536
- f"CGI script exited with code {process.returncode}: {error_msg}"
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
- # Fall back to a placeholder (in production, use IP from transport)
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) # type: ignore[misc]
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: Request | None = None) -> GeminiResponse:
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 = True
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=response.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