xitzin 0.4.0__py3-none-any.whl → 0.6.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.
- xitzin/__init__.py +7 -0
- xitzin/application.py +186 -71
- xitzin/middleware.py +184 -0
- xitzin/staticfiles.py +487 -0
- {xitzin-0.4.0.dist-info → xitzin-0.6.0.dist-info}/METADATA +1 -1
- {xitzin-0.4.0.dist-info → xitzin-0.6.0.dist-info}/RECORD +7 -6
- {xitzin-0.4.0.dist-info → xitzin-0.6.0.dist-info}/WHEEL +1 -1
xitzin/__init__.py
CHANGED
|
@@ -22,7 +22,9 @@ 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
|
|
27
|
+
from .staticfiles import StaticFiles, StaticFilesConfig
|
|
26
28
|
from .exceptions import (
|
|
27
29
|
BadRequest,
|
|
28
30
|
CertificateNotAuthorized,
|
|
@@ -55,6 +57,8 @@ __all__ = [
|
|
|
55
57
|
"Input",
|
|
56
58
|
"Redirect",
|
|
57
59
|
"Link",
|
|
60
|
+
# Middleware
|
|
61
|
+
"VirtualHostMiddleware",
|
|
58
62
|
# CGI support
|
|
59
63
|
"CGIConfig",
|
|
60
64
|
"CGIHandler",
|
|
@@ -63,6 +67,9 @@ __all__ = [
|
|
|
63
67
|
"SCGIApp",
|
|
64
68
|
"SCGIConfig",
|
|
65
69
|
"SCGIHandler",
|
|
70
|
+
# Static files
|
|
71
|
+
"StaticFiles",
|
|
72
|
+
"StaticFilesConfig",
|
|
66
73
|
# Exceptions
|
|
67
74
|
"GeminiException",
|
|
68
75
|
"InputRequired",
|
xitzin/application.py
CHANGED
|
@@ -26,6 +26,7 @@ from .responses import Input, Redirect, convert_response
|
|
|
26
26
|
from .routing import MountedRoute, Route, Router, TitanRoute
|
|
27
27
|
|
|
28
28
|
if TYPE_CHECKING:
|
|
29
|
+
from .staticfiles import StaticFiles
|
|
29
30
|
from .tasks import BackgroundTask
|
|
30
31
|
from .templating import TemplateEngine
|
|
31
32
|
|
|
@@ -399,6 +400,127 @@ class Xitzin:
|
|
|
399
400
|
|
|
400
401
|
self.mount(path, handler, name=name)
|
|
401
402
|
|
|
403
|
+
def static(
|
|
404
|
+
self,
|
|
405
|
+
path: str,
|
|
406
|
+
directory: Path | str,
|
|
407
|
+
*,
|
|
408
|
+
name: str | None = None,
|
|
409
|
+
index_files: list[str] | None = None,
|
|
410
|
+
directory_listing: bool = False,
|
|
411
|
+
max_file_size: int = 100 * 1024 * 1024,
|
|
412
|
+
mime_types: dict[str, str] | None = None,
|
|
413
|
+
follow_symlinks: bool = False,
|
|
414
|
+
) -> "StaticFiles":
|
|
415
|
+
"""Mount a static file directory at a path prefix.
|
|
416
|
+
|
|
417
|
+
This is a convenience method that creates a StaticFiles handler and
|
|
418
|
+
mounts it. Returns the handler so you can add a custom 404 handler.
|
|
419
|
+
|
|
420
|
+
Args:
|
|
421
|
+
path: Mount point prefix (e.g., "/files", "/static").
|
|
422
|
+
directory: Directory to serve files from.
|
|
423
|
+
name: Optional name for the mount.
|
|
424
|
+
index_files: Files to serve for directory requests.
|
|
425
|
+
Defaults to ["index.gmi", "index.gemini"].
|
|
426
|
+
directory_listing: Enable directory listing when no index found.
|
|
427
|
+
max_file_size: Maximum file size to serve (bytes). Default: 100 MiB.
|
|
428
|
+
mime_types: Custom MIME type mappings by extension.
|
|
429
|
+
follow_symlinks: Whether to follow symbolic links.
|
|
430
|
+
|
|
431
|
+
Returns:
|
|
432
|
+
The StaticFiles handler, for adding custom 404 handling.
|
|
433
|
+
|
|
434
|
+
Example:
|
|
435
|
+
# Simple usage
|
|
436
|
+
app.static("/files", "./public")
|
|
437
|
+
|
|
438
|
+
# With directory listing
|
|
439
|
+
app.static("/docs", "./documentation", directory_listing=True)
|
|
440
|
+
|
|
441
|
+
# With custom 404 handler
|
|
442
|
+
static = app.static("/images", "./static/images")
|
|
443
|
+
|
|
444
|
+
@static.not_found
|
|
445
|
+
def image_not_found(request, path_info):
|
|
446
|
+
return "# Image Not Found"
|
|
447
|
+
"""
|
|
448
|
+
from .staticfiles import StaticFiles
|
|
449
|
+
|
|
450
|
+
handler = StaticFiles(
|
|
451
|
+
directory,
|
|
452
|
+
index_files=index_files,
|
|
453
|
+
directory_listing=directory_listing,
|
|
454
|
+
max_file_size=max_file_size,
|
|
455
|
+
mime_types=mime_types,
|
|
456
|
+
follow_symlinks=follow_symlinks,
|
|
457
|
+
)
|
|
458
|
+
self.mount(path, handler, name=name)
|
|
459
|
+
return handler
|
|
460
|
+
|
|
461
|
+
def vhost(
|
|
462
|
+
self,
|
|
463
|
+
hosts: dict[str, "Xitzin"],
|
|
464
|
+
*,
|
|
465
|
+
default_app: "Xitzin | None" = None,
|
|
466
|
+
fallback_status: int = 53,
|
|
467
|
+
fallback_handler: Callable[[Request], Any] | None = None,
|
|
468
|
+
) -> None:
|
|
469
|
+
"""Configure virtual hosting for this application.
|
|
470
|
+
|
|
471
|
+
This is a convenience method that creates and registers VirtualHostMiddleware.
|
|
472
|
+
The middleware routes requests to different apps based on hostname.
|
|
473
|
+
|
|
474
|
+
Args:
|
|
475
|
+
hosts: Mapping of hostname patterns to Xitzin apps.
|
|
476
|
+
Patterns can be exact ("example.com") or wildcards ("*.example.com").
|
|
477
|
+
Exact matches are checked first, then wildcards in definition order.
|
|
478
|
+
default_app: Default app when no pattern matches.
|
|
479
|
+
fallback_status: Status code for unmatched hosts (default: 53).
|
|
480
|
+
Common values: 53 (Proxy Refused), 51 (Not Found), 59 (Bad Request).
|
|
481
|
+
fallback_handler: Custom handler for unmatched hosts.
|
|
482
|
+
Takes precedence over default_app and fallback_status.
|
|
483
|
+
|
|
484
|
+
Example:
|
|
485
|
+
main_app = Xitzin(title="Main")
|
|
486
|
+
blog_app = Xitzin(title="Blog")
|
|
487
|
+
api_app = Xitzin(title="API")
|
|
488
|
+
|
|
489
|
+
@blog_app.gemini("/")
|
|
490
|
+
def blog_home(request: Request):
|
|
491
|
+
return "# Blog Home"
|
|
492
|
+
|
|
493
|
+
@api_app.gemini("/")
|
|
494
|
+
def api_home(request: Request):
|
|
495
|
+
return "# API Home"
|
|
496
|
+
|
|
497
|
+
@main_app.gemini("/")
|
|
498
|
+
def main_home(request: Request):
|
|
499
|
+
return "# Main Home"
|
|
500
|
+
|
|
501
|
+
# Configure as gateway
|
|
502
|
+
main_app.vhost({
|
|
503
|
+
"blog.example.com": blog_app,
|
|
504
|
+
"*.api.example.com": api_app,
|
|
505
|
+
}, default_app=main_app)
|
|
506
|
+
|
|
507
|
+
main_app.run()
|
|
508
|
+
"""
|
|
509
|
+
from .middleware import VirtualHostMiddleware
|
|
510
|
+
|
|
511
|
+
vhost_mw = VirtualHostMiddleware(
|
|
512
|
+
hosts,
|
|
513
|
+
default_app=default_app,
|
|
514
|
+
fallback_status=fallback_status,
|
|
515
|
+
fallback_handler=fallback_handler,
|
|
516
|
+
)
|
|
517
|
+
|
|
518
|
+
@self.middleware
|
|
519
|
+
async def virtual_host_dispatcher(
|
|
520
|
+
request: Request, call_next: Callable[..., Any]
|
|
521
|
+
) -> GeminiResponse:
|
|
522
|
+
return await vhost_mw(request, call_next)
|
|
523
|
+
|
|
402
524
|
def on_startup(self, handler: Callable[[], Any]) -> Callable[[], Any]:
|
|
403
525
|
"""Register a startup event handler.
|
|
404
526
|
|
|
@@ -559,64 +681,56 @@ class Xitzin:
|
|
|
559
681
|
"""
|
|
560
682
|
request = Request(raw_request, self)
|
|
561
683
|
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
async def call_mounted_handler(req: Request) -> GeminiResponse:
|
|
684
|
+
# Build middleware chain around the entire routing logic
|
|
685
|
+
async def route_and_handle(req: Request) -> GeminiResponse:
|
|
686
|
+
try:
|
|
687
|
+
# Check mounted routes first
|
|
688
|
+
mount_match = self._router.match_mount(req.path)
|
|
689
|
+
if mount_match is not None:
|
|
690
|
+
mounted_route, path_info = mount_match
|
|
570
691
|
result = await mounted_route.call_handler(req, path_info)
|
|
571
692
|
return convert_response(result, req)
|
|
572
693
|
|
|
573
|
-
#
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
return await handler(request)
|
|
579
|
-
|
|
580
|
-
# Match regular route
|
|
581
|
-
match = self._router.match(request.path)
|
|
582
|
-
if match is None:
|
|
583
|
-
raise NotFound(f"No route matches: {request.path}")
|
|
694
|
+
# Match regular route
|
|
695
|
+
match = self._router.match(req.path)
|
|
696
|
+
if match is None:
|
|
697
|
+
raise NotFound(f"No route matches: {req.path}")
|
|
584
698
|
|
|
585
|
-
|
|
699
|
+
route, params = match
|
|
586
700
|
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
701
|
+
# Handle input flow
|
|
702
|
+
if route.input_prompt and not req.query:
|
|
703
|
+
return Input(
|
|
704
|
+
route.input_prompt, route.sensitive_input
|
|
705
|
+
).to_gemini_response()
|
|
592
706
|
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
707
|
+
# Add query to params for input routes
|
|
708
|
+
if route.input_prompt and req.query:
|
|
709
|
+
params["query"] = req.query
|
|
596
710
|
|
|
597
|
-
|
|
598
|
-
async def call_handler(req: Request) -> GeminiResponse:
|
|
711
|
+
# Call the handler
|
|
599
712
|
result = await route.call_handler(req, params)
|
|
600
713
|
return convert_response(result, req)
|
|
601
714
|
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
715
|
+
except GeminiException as e:
|
|
716
|
+
return GeminiResponse(status=e.status_code, meta=e.message)
|
|
717
|
+
except Exception:
|
|
718
|
+
# Log the error and return a generic failure
|
|
719
|
+
import traceback
|
|
606
720
|
|
|
607
|
-
|
|
721
|
+
traceback.print_exc()
|
|
722
|
+
return GeminiResponse(
|
|
723
|
+
status=StatusCode.TEMPORARY_FAILURE,
|
|
724
|
+
meta="Internal server error",
|
|
725
|
+
)
|
|
608
726
|
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
727
|
+
# Apply middleware around the entire routing logic
|
|
728
|
+
# This allows middleware to intercept requests before routing
|
|
729
|
+
handler = route_and_handle
|
|
730
|
+
for mw in reversed(self._middleware):
|
|
731
|
+
handler = self._wrap_middleware(mw, handler)
|
|
614
732
|
|
|
615
|
-
|
|
616
|
-
return GeminiResponse(
|
|
617
|
-
status=StatusCode.TEMPORARY_FAILURE,
|
|
618
|
-
meta="Internal server error",
|
|
619
|
-
)
|
|
733
|
+
return await handler(request)
|
|
620
734
|
|
|
621
735
|
async def _handle_titan_request(
|
|
622
736
|
self, raw_request: NauyacaTitanRequest
|
|
@@ -627,41 +741,42 @@ class Xitzin:
|
|
|
627
741
|
"""
|
|
628
742
|
request = TitanRequest(raw_request, self)
|
|
629
743
|
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
744
|
+
# Build middleware chain around the entire routing logic
|
|
745
|
+
async def route_and_handle(req: TitanRequest) -> GeminiResponse:
|
|
746
|
+
try:
|
|
747
|
+
# Match Titan route
|
|
748
|
+
match = self._router.match_titan(req.path)
|
|
749
|
+
if match is None:
|
|
750
|
+
raise NotFound(f"No Titan route matches: {req.path}")
|
|
635
751
|
|
|
636
|
-
|
|
752
|
+
route, params = match
|
|
637
753
|
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
754
|
+
# Validate auth token if required
|
|
755
|
+
if route.auth_tokens is not None:
|
|
756
|
+
if not req.token or req.token not in route.auth_tokens:
|
|
757
|
+
raise CertificateRequired("Valid authentication token required")
|
|
642
758
|
|
|
643
|
-
|
|
644
|
-
async def call_handler(req: TitanRequest) -> GeminiResponse:
|
|
759
|
+
# Call the handler
|
|
645
760
|
result = await route.call_handler(req, params)
|
|
646
761
|
return convert_response(result, req)
|
|
647
762
|
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
763
|
+
except GeminiException as e:
|
|
764
|
+
return GeminiResponse(status=e.status_code, meta=e.message)
|
|
765
|
+
except Exception:
|
|
766
|
+
import traceback
|
|
652
767
|
|
|
653
|
-
|
|
768
|
+
traceback.print_exc()
|
|
769
|
+
return GeminiResponse(
|
|
770
|
+
status=StatusCode.TEMPORARY_FAILURE,
|
|
771
|
+
meta="Internal server error",
|
|
772
|
+
)
|
|
654
773
|
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
774
|
+
# Apply middleware around the entire routing logic
|
|
775
|
+
handler = route_and_handle
|
|
776
|
+
for mw in reversed(self._middleware):
|
|
777
|
+
handler = self._wrap_middleware(mw, handler)
|
|
659
778
|
|
|
660
|
-
|
|
661
|
-
return GeminiResponse(
|
|
662
|
-
status=StatusCode.TEMPORARY_FAILURE,
|
|
663
|
-
meta="Internal server error",
|
|
664
|
-
)
|
|
779
|
+
return await handler(request)
|
|
665
780
|
|
|
666
781
|
def _wrap_middleware(
|
|
667
782
|
self,
|
xitzin/middleware.py
CHANGED
|
@@ -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
|
|
@@ -382,3 +384,185 @@ class UserSessionMiddleware(BaseMiddleware):
|
|
|
382
384
|
currsize=len(self._async_cache),
|
|
383
385
|
)
|
|
384
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
|
xitzin/staticfiles.py
ADDED
|
@@ -0,0 +1,487 @@
|
|
|
1
|
+
"""Static file serving for Xitzin applications.
|
|
2
|
+
|
|
3
|
+
This module provides static file serving capabilities for Xitzin,
|
|
4
|
+
enabling capsules to serve files from a directory with configurable
|
|
5
|
+
options for directory listings, MIME types, and security settings.
|
|
6
|
+
|
|
7
|
+
Example:
|
|
8
|
+
from xitzin import Xitzin
|
|
9
|
+
from xitzin.staticfiles import StaticFiles
|
|
10
|
+
|
|
11
|
+
app = Xitzin()
|
|
12
|
+
|
|
13
|
+
# Mount static files at a path
|
|
14
|
+
app.mount("/files", StaticFiles("./public"))
|
|
15
|
+
|
|
16
|
+
# Or use the convenience method
|
|
17
|
+
app.static("/docs", "./documentation", directory_listing=True)
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import asyncio
|
|
23
|
+
import mimetypes
|
|
24
|
+
from dataclasses import dataclass, field
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import TYPE_CHECKING, Any, Callable
|
|
27
|
+
|
|
28
|
+
from nauyaca.protocol.response import GeminiResponse
|
|
29
|
+
from nauyaca.protocol.status import StatusCode
|
|
30
|
+
|
|
31
|
+
from .exceptions import BadRequest, NotFound
|
|
32
|
+
from .responses import Redirect
|
|
33
|
+
|
|
34
|
+
if TYPE_CHECKING:
|
|
35
|
+
from .requests import Request
|
|
36
|
+
|
|
37
|
+
# Default MIME types for Gemini/common files
|
|
38
|
+
DEFAULT_MIME_TYPES: dict[str, str] = {
|
|
39
|
+
".gmi": "text/gemini",
|
|
40
|
+
".gemini": "text/gemini",
|
|
41
|
+
".txt": "text/plain",
|
|
42
|
+
".md": "text/markdown",
|
|
43
|
+
".html": "text/html",
|
|
44
|
+
".css": "text/css",
|
|
45
|
+
".js": "text/javascript",
|
|
46
|
+
".json": "application/json",
|
|
47
|
+
".xml": "application/xml",
|
|
48
|
+
".png": "image/png",
|
|
49
|
+
".jpg": "image/jpeg",
|
|
50
|
+
".jpeg": "image/jpeg",
|
|
51
|
+
".gif": "image/gif",
|
|
52
|
+
".svg": "image/svg+xml",
|
|
53
|
+
".webp": "image/webp",
|
|
54
|
+
".pdf": "application/pdf",
|
|
55
|
+
".zip": "application/zip",
|
|
56
|
+
".gz": "application/gzip",
|
|
57
|
+
".tar": "application/x-tar",
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
# MIME types that should be read as binary
|
|
61
|
+
BINARY_MIME_PREFIXES = (
|
|
62
|
+
"image/",
|
|
63
|
+
"video/",
|
|
64
|
+
"audio/",
|
|
65
|
+
"application/pdf",
|
|
66
|
+
"application/zip",
|
|
67
|
+
"application/gzip",
|
|
68
|
+
"application/x-tar",
|
|
69
|
+
"application/octet-stream",
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass
|
|
74
|
+
class StaticFilesConfig:
|
|
75
|
+
"""Configuration for static file serving.
|
|
76
|
+
|
|
77
|
+
Attributes:
|
|
78
|
+
index_files: Files to serve for directory requests.
|
|
79
|
+
directory_listing: Enable directory listing when no index found.
|
|
80
|
+
max_file_size: Maximum file size to serve (bytes).
|
|
81
|
+
mime_types: Custom MIME type mappings by extension.
|
|
82
|
+
follow_symlinks: Whether to follow symbolic links.
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
index_files: list[str] = field(
|
|
86
|
+
default_factory=lambda: ["index.gmi", "index.gemini"]
|
|
87
|
+
)
|
|
88
|
+
directory_listing: bool = False
|
|
89
|
+
max_file_size: int = 100 * 1024 * 1024 # 100 MiB
|
|
90
|
+
mime_types: dict[str, str] = field(default_factory=dict)
|
|
91
|
+
follow_symlinks: bool = False
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _format_file_size(size: int) -> str:
|
|
95
|
+
"""Format file size in human-readable form.
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
size: Size in bytes.
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
Human-readable size string (e.g., "1.5 KB", "100 MB").
|
|
102
|
+
"""
|
|
103
|
+
value: float = float(size)
|
|
104
|
+
for unit in ("B", "KB", "MB", "GB", "TB"):
|
|
105
|
+
if value < 1024:
|
|
106
|
+
if unit == "B":
|
|
107
|
+
return f"{int(value)} {unit}"
|
|
108
|
+
if value < 10:
|
|
109
|
+
return f"{value:.1f} {unit}"
|
|
110
|
+
return f"{int(value)} {unit}"
|
|
111
|
+
value /= 1024
|
|
112
|
+
return f"{int(value)} PB"
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class StaticFiles:
|
|
116
|
+
"""Serve static files from a directory.
|
|
117
|
+
|
|
118
|
+
This handler serves files from a specified directory, with support
|
|
119
|
+
for directory indexes, directory listings, custom MIME types,
|
|
120
|
+
and security controls.
|
|
121
|
+
|
|
122
|
+
Example:
|
|
123
|
+
from xitzin.staticfiles import StaticFiles
|
|
124
|
+
|
|
125
|
+
# Basic usage
|
|
126
|
+
handler = StaticFiles("./public")
|
|
127
|
+
app.mount("/files", handler)
|
|
128
|
+
|
|
129
|
+
# With configuration
|
|
130
|
+
handler = StaticFiles(
|
|
131
|
+
"./docs",
|
|
132
|
+
directory_listing=True,
|
|
133
|
+
max_file_size=50 * 1024 * 1024,
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
@handler.not_found
|
|
137
|
+
def custom_404(request, path_info):
|
|
138
|
+
return "# File Not Found"
|
|
139
|
+
|
|
140
|
+
app.mount("/docs", handler)
|
|
141
|
+
"""
|
|
142
|
+
|
|
143
|
+
def __init__(
|
|
144
|
+
self,
|
|
145
|
+
directory: Path | str,
|
|
146
|
+
*,
|
|
147
|
+
config: StaticFilesConfig | None = None,
|
|
148
|
+
index_files: list[str] | None = None,
|
|
149
|
+
directory_listing: bool | None = None,
|
|
150
|
+
max_file_size: int | None = None,
|
|
151
|
+
mime_types: dict[str, str] | None = None,
|
|
152
|
+
follow_symlinks: bool | None = None,
|
|
153
|
+
) -> None:
|
|
154
|
+
"""Create a static file handler.
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
directory: Directory to serve files from.
|
|
158
|
+
config: Configuration object (overridden by other params).
|
|
159
|
+
index_files: Files to serve for directory requests.
|
|
160
|
+
directory_listing: Enable directory listing when no index found.
|
|
161
|
+
max_file_size: Maximum file size to serve (bytes).
|
|
162
|
+
mime_types: Custom MIME type mappings by extension.
|
|
163
|
+
follow_symlinks: Whether to follow symbolic links.
|
|
164
|
+
|
|
165
|
+
Raises:
|
|
166
|
+
ValueError: If directory doesn't exist or isn't a directory.
|
|
167
|
+
"""
|
|
168
|
+
self.directory = Path(directory).resolve()
|
|
169
|
+
self._not_found_handler: Callable[[Any, str], Any] | None = None
|
|
170
|
+
|
|
171
|
+
if not self.directory.exists():
|
|
172
|
+
raise ValueError(f"Directory not found: {directory}")
|
|
173
|
+
if not self.directory.is_dir():
|
|
174
|
+
raise ValueError(f"Path is not a directory: {directory}")
|
|
175
|
+
|
|
176
|
+
# Start with config defaults, override with explicit parameters
|
|
177
|
+
base_config = config or StaticFilesConfig()
|
|
178
|
+
|
|
179
|
+
self.index_files = (
|
|
180
|
+
index_files if index_files is not None else base_config.index_files
|
|
181
|
+
)
|
|
182
|
+
self.directory_listing = (
|
|
183
|
+
directory_listing
|
|
184
|
+
if directory_listing is not None
|
|
185
|
+
else base_config.directory_listing
|
|
186
|
+
)
|
|
187
|
+
self.max_file_size = (
|
|
188
|
+
max_file_size if max_file_size is not None else base_config.max_file_size
|
|
189
|
+
)
|
|
190
|
+
self.follow_symlinks = (
|
|
191
|
+
follow_symlinks
|
|
192
|
+
if follow_symlinks is not None
|
|
193
|
+
else base_config.follow_symlinks
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
# Build MIME type mapping: defaults + config + explicit
|
|
197
|
+
self._mime_types = {**DEFAULT_MIME_TYPES}
|
|
198
|
+
self._mime_types.update(base_config.mime_types)
|
|
199
|
+
if mime_types:
|
|
200
|
+
self._mime_types.update(mime_types)
|
|
201
|
+
|
|
202
|
+
def not_found(
|
|
203
|
+
self, handler: Callable[[Any, str], Any]
|
|
204
|
+
) -> Callable[[Any, str], Any]:
|
|
205
|
+
"""Register a custom not-found handler.
|
|
206
|
+
|
|
207
|
+
The handler receives (request, path_info) and should return a response.
|
|
208
|
+
|
|
209
|
+
Example:
|
|
210
|
+
@handler.not_found
|
|
211
|
+
def custom_404(request, path_info):
|
|
212
|
+
return f"# Not Found\\n\\nFile {path_info} doesn't exist."
|
|
213
|
+
"""
|
|
214
|
+
self._not_found_handler = handler
|
|
215
|
+
return handler
|
|
216
|
+
|
|
217
|
+
async def __call__(self, request: Request, path_info: str) -> GeminiResponse:
|
|
218
|
+
"""Handle a request for a static file.
|
|
219
|
+
|
|
220
|
+
Args:
|
|
221
|
+
request: The Gemini request.
|
|
222
|
+
path_info: Path after the mount prefix (e.g., "/docs/page.gmi").
|
|
223
|
+
|
|
224
|
+
Returns:
|
|
225
|
+
GeminiResponse with the file content or error.
|
|
226
|
+
|
|
227
|
+
Raises:
|
|
228
|
+
NotFound: If file doesn't exist (and no custom handler).
|
|
229
|
+
BadRequest: If path validation fails.
|
|
230
|
+
"""
|
|
231
|
+
try:
|
|
232
|
+
# Normalize path_info
|
|
233
|
+
path_info = path_info.lstrip("/") if path_info else ""
|
|
234
|
+
|
|
235
|
+
# Resolve and validate path
|
|
236
|
+
file_path = self._resolve_path(path_info)
|
|
237
|
+
|
|
238
|
+
# Handle directory
|
|
239
|
+
if file_path.is_dir():
|
|
240
|
+
return await self._serve_directory(request, file_path, path_info)
|
|
241
|
+
|
|
242
|
+
# Handle file
|
|
243
|
+
return await self._serve_file(file_path)
|
|
244
|
+
|
|
245
|
+
except NotFound:
|
|
246
|
+
if self._not_found_handler is not None:
|
|
247
|
+
result = self._not_found_handler(request, path_info)
|
|
248
|
+
if asyncio.iscoroutine(result):
|
|
249
|
+
result = await result
|
|
250
|
+
return self._convert_handler_result(result)
|
|
251
|
+
raise
|
|
252
|
+
|
|
253
|
+
def _resolve_path(self, path_info: str) -> Path:
|
|
254
|
+
"""Resolve path_info to a validated filesystem path.
|
|
255
|
+
|
|
256
|
+
Args:
|
|
257
|
+
path_info: Requested path relative to mount point.
|
|
258
|
+
|
|
259
|
+
Returns:
|
|
260
|
+
Resolved Path object.
|
|
261
|
+
|
|
262
|
+
Raises:
|
|
263
|
+
BadRequest: If path contains traversal attempts.
|
|
264
|
+
NotFound: If resolved path doesn't exist.
|
|
265
|
+
"""
|
|
266
|
+
# Reject obvious traversal attempts early
|
|
267
|
+
if ".." in path_info:
|
|
268
|
+
raise BadRequest("Invalid path")
|
|
269
|
+
|
|
270
|
+
# Build candidate path
|
|
271
|
+
if path_info:
|
|
272
|
+
candidate = self.directory / path_info
|
|
273
|
+
else:
|
|
274
|
+
candidate = self.directory
|
|
275
|
+
|
|
276
|
+
# Resolve the path, handling symlinks based on config
|
|
277
|
+
if self.follow_symlinks:
|
|
278
|
+
resolved = candidate.resolve()
|
|
279
|
+
else:
|
|
280
|
+
# Resolve parent but not the final component
|
|
281
|
+
resolved = candidate.parent.resolve() / candidate.name
|
|
282
|
+
# Check if final component is a symlink
|
|
283
|
+
if resolved.is_symlink():
|
|
284
|
+
raise BadRequest("Symbolic links not allowed")
|
|
285
|
+
# Now fully resolve to catch any issues
|
|
286
|
+
resolved = resolved.resolve()
|
|
287
|
+
|
|
288
|
+
# Security check: ensure path is within allowed directory
|
|
289
|
+
try:
|
|
290
|
+
resolved.relative_to(self.directory)
|
|
291
|
+
except ValueError:
|
|
292
|
+
raise BadRequest("Invalid path") from None
|
|
293
|
+
|
|
294
|
+
# Check existence
|
|
295
|
+
if not resolved.exists():
|
|
296
|
+
raise NotFound("File not found")
|
|
297
|
+
|
|
298
|
+
return resolved
|
|
299
|
+
|
|
300
|
+
def _get_mime_type(self, file_path: Path) -> str:
|
|
301
|
+
"""Determine MIME type for a file.
|
|
302
|
+
|
|
303
|
+
Args:
|
|
304
|
+
file_path: Path to the file.
|
|
305
|
+
|
|
306
|
+
Returns:
|
|
307
|
+
MIME type string.
|
|
308
|
+
"""
|
|
309
|
+
suffix = file_path.suffix.lower()
|
|
310
|
+
|
|
311
|
+
# Check custom mappings first
|
|
312
|
+
if suffix in self._mime_types:
|
|
313
|
+
return self._mime_types[suffix]
|
|
314
|
+
|
|
315
|
+
# Try stdlib mimetypes
|
|
316
|
+
guessed, _ = mimetypes.guess_type(str(file_path))
|
|
317
|
+
if guessed:
|
|
318
|
+
return guessed
|
|
319
|
+
|
|
320
|
+
# Default to text/gemini for Gemini-centric behavior
|
|
321
|
+
return "text/gemini"
|
|
322
|
+
|
|
323
|
+
def _is_binary_mime(self, mime_type: str) -> bool:
|
|
324
|
+
"""Check if MIME type indicates binary content.
|
|
325
|
+
|
|
326
|
+
Args:
|
|
327
|
+
mime_type: MIME type string.
|
|
328
|
+
|
|
329
|
+
Returns:
|
|
330
|
+
True if content should be read as binary.
|
|
331
|
+
"""
|
|
332
|
+
return mime_type.startswith(BINARY_MIME_PREFIXES)
|
|
333
|
+
|
|
334
|
+
async def _serve_file(self, file_path: Path) -> GeminiResponse:
|
|
335
|
+
"""Serve a file with appropriate MIME type.
|
|
336
|
+
|
|
337
|
+
Args:
|
|
338
|
+
file_path: Path to the file.
|
|
339
|
+
|
|
340
|
+
Returns:
|
|
341
|
+
GeminiResponse with file content.
|
|
342
|
+
|
|
343
|
+
Raises:
|
|
344
|
+
BadRequest: If file exceeds size limit.
|
|
345
|
+
NotFound: If file cannot be read.
|
|
346
|
+
"""
|
|
347
|
+
# Check file size before reading
|
|
348
|
+
try:
|
|
349
|
+
size = file_path.stat().st_size
|
|
350
|
+
except OSError:
|
|
351
|
+
raise NotFound("File not found") from None
|
|
352
|
+
|
|
353
|
+
if size > self.max_file_size:
|
|
354
|
+
raise BadRequest(
|
|
355
|
+
f"File too large ({_format_file_size(size)}, "
|
|
356
|
+
f"max {_format_file_size(self.max_file_size)})"
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
mime_type = self._get_mime_type(file_path)
|
|
360
|
+
is_binary = self._is_binary_mime(mime_type)
|
|
361
|
+
|
|
362
|
+
try:
|
|
363
|
+
if is_binary:
|
|
364
|
+
# Read binary in thread to avoid blocking
|
|
365
|
+
body: str | bytes = await asyncio.to_thread(file_path.read_bytes)
|
|
366
|
+
else:
|
|
367
|
+
body = await asyncio.to_thread(file_path.read_text, encoding="utf-8")
|
|
368
|
+
except UnicodeDecodeError:
|
|
369
|
+
# Fall back to binary if text decode fails
|
|
370
|
+
body = await asyncio.to_thread(file_path.read_bytes)
|
|
371
|
+
except OSError:
|
|
372
|
+
raise NotFound("File not found") from None
|
|
373
|
+
|
|
374
|
+
return GeminiResponse(
|
|
375
|
+
status=StatusCode.SUCCESS,
|
|
376
|
+
meta=mime_type,
|
|
377
|
+
body=body,
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
async def _serve_directory(
|
|
381
|
+
self, request: Request, dir_path: Path, path_info: str
|
|
382
|
+
) -> GeminiResponse:
|
|
383
|
+
"""Serve a directory request.
|
|
384
|
+
|
|
385
|
+
Args:
|
|
386
|
+
request: The Gemini request.
|
|
387
|
+
dir_path: Path to the directory.
|
|
388
|
+
path_info: Original path_info for this request.
|
|
389
|
+
|
|
390
|
+
Returns:
|
|
391
|
+
GeminiResponse with index file, directory listing, or redirect.
|
|
392
|
+
|
|
393
|
+
Raises:
|
|
394
|
+
NotFound: If no index and directory listing disabled.
|
|
395
|
+
"""
|
|
396
|
+
# Ensure trailing slash for directories
|
|
397
|
+
if path_info and not request.path.endswith("/"):
|
|
398
|
+
redirect_url = request.path + "/"
|
|
399
|
+
return Redirect(redirect_url, permanent=False).to_gemini_response()
|
|
400
|
+
|
|
401
|
+
# Try to find an index file
|
|
402
|
+
for index_name in self.index_files:
|
|
403
|
+
index_path = dir_path / index_name
|
|
404
|
+
if index_path.is_file():
|
|
405
|
+
return await self._serve_file(index_path)
|
|
406
|
+
|
|
407
|
+
# Generate directory listing if enabled
|
|
408
|
+
if self.directory_listing:
|
|
409
|
+
listing = self._generate_directory_listing(dir_path, path_info)
|
|
410
|
+
return GeminiResponse(
|
|
411
|
+
status=StatusCode.SUCCESS,
|
|
412
|
+
meta="text/gemini",
|
|
413
|
+
body=listing,
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
raise NotFound("Directory index not found")
|
|
417
|
+
|
|
418
|
+
def _generate_directory_listing(self, dir_path: Path, request_path: str) -> str:
|
|
419
|
+
"""Generate a Gemini directory listing.
|
|
420
|
+
|
|
421
|
+
Args:
|
|
422
|
+
dir_path: Path to the directory.
|
|
423
|
+
request_path: The request path for display.
|
|
424
|
+
|
|
425
|
+
Returns:
|
|
426
|
+
Gemtext directory listing.
|
|
427
|
+
"""
|
|
428
|
+
display_path = "/" + request_path if request_path else "/"
|
|
429
|
+
lines = [f"# Index of {display_path}", ""]
|
|
430
|
+
|
|
431
|
+
# Parent directory link (if not at root)
|
|
432
|
+
if request_path:
|
|
433
|
+
lines.append("=> ../ ..")
|
|
434
|
+
lines.append("")
|
|
435
|
+
|
|
436
|
+
# Collect and sort entries: directories first, then files
|
|
437
|
+
entries = []
|
|
438
|
+
try:
|
|
439
|
+
for entry in dir_path.iterdir():
|
|
440
|
+
# Skip hidden files
|
|
441
|
+
if entry.name.startswith("."):
|
|
442
|
+
continue
|
|
443
|
+
|
|
444
|
+
# Skip symlinks if not following them
|
|
445
|
+
if not self.follow_symlinks and entry.is_symlink():
|
|
446
|
+
continue
|
|
447
|
+
|
|
448
|
+
entries.append(entry)
|
|
449
|
+
except OSError:
|
|
450
|
+
pass
|
|
451
|
+
|
|
452
|
+
# Sort: directories first, then alphabetically by name
|
|
453
|
+
entries.sort(key=lambda p: (not p.is_dir(), p.name.lower()))
|
|
454
|
+
|
|
455
|
+
# Generate links
|
|
456
|
+
for entry in entries:
|
|
457
|
+
name = entry.name
|
|
458
|
+
if entry.is_dir():
|
|
459
|
+
lines.append(f"=> {name}/ {name}/")
|
|
460
|
+
else:
|
|
461
|
+
try:
|
|
462
|
+
size = _format_file_size(entry.stat().st_size)
|
|
463
|
+
lines.append(f"=> {name} {name} ({size})")
|
|
464
|
+
except OSError:
|
|
465
|
+
lines.append(f"=> {name} {name}")
|
|
466
|
+
|
|
467
|
+
return "\n".join(lines)
|
|
468
|
+
|
|
469
|
+
def _convert_handler_result(self, result: Any) -> GeminiResponse:
|
|
470
|
+
"""Convert a custom handler result to GeminiResponse.
|
|
471
|
+
|
|
472
|
+
Args:
|
|
473
|
+
result: Handler return value.
|
|
474
|
+
|
|
475
|
+
Returns:
|
|
476
|
+
GeminiResponse.
|
|
477
|
+
"""
|
|
478
|
+
# Import here to avoid circular import
|
|
479
|
+
from .responses import convert_response
|
|
480
|
+
|
|
481
|
+
return convert_response(result)
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
__all__ = [
|
|
485
|
+
"StaticFiles",
|
|
486
|
+
"StaticFilesConfig",
|
|
487
|
+
]
|
|
@@ -1,18 +1,19 @@
|
|
|
1
|
-
xitzin/__init__.py,sha256=
|
|
2
|
-
xitzin/application.py,sha256=
|
|
1
|
+
xitzin/__init__.py,sha256=EHeBz-bmQ02KwVlmT7WUtp0MVQGTDcqR-m8uIibCJD4,2062
|
|
2
|
+
xitzin/application.py,sha256=OkdaHt0aSRgo6NEEUA1ZwMy08eYWefzReUsZeBowVnY,32135
|
|
3
3
|
xitzin/auth.py,sha256=hHhrP_fYToY6J6CCU8vgAsWbYn6K16LUxkeDMzZTF2Q,5304
|
|
4
4
|
xitzin/cgi.py,sha256=nggSuQ0gXIKdBKWHqH_CNZ6UlVGaB3VAOu3ZRPc43ek,18046
|
|
5
5
|
xitzin/exceptions.py,sha256=82z-CjyC0FwFbo9hGTjjmurlL_Vd4rTVdgkmQoFLXT0,3883
|
|
6
|
-
xitzin/middleware.py,sha256=
|
|
6
|
+
xitzin/middleware.py,sha256=rES7C1gujvP-1evpXfqng9uZ91rzJdqfob-nvGBtTAo,19042
|
|
7
7
|
xitzin/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
8
|
xitzin/requests.py,sha256=1mbm5F8oywLPNpSdh2inn7K1IEeI3H5meOwj8jldi1Q,7783
|
|
9
9
|
xitzin/responses.py,sha256=QVH4B-qVHP7hxi0hIrOJhdCiHDf4PDCpSZ1fAD8iNkk,6525
|
|
10
10
|
xitzin/routing.py,sha256=8Kl3FV6EWZ6nW0bDTqJ-qysNyJ1iSRtCKicCAai8Lmk,18093
|
|
11
11
|
xitzin/scgi.py,sha256=t-ixEwrQR-bIRj56DkzBsvGyN75E5R0gasIjMfq5LFY,13665
|
|
12
12
|
xitzin/sqlmodel.py,sha256=lZvDzYAgnG8S2K-EYnx6PkO7D68pjM06uQLSKUZ9yY4,4500
|
|
13
|
+
xitzin/staticfiles.py,sha256=jSbEdUIDq4aktP_-CX5US0IObrUBPF7M7eHUr28VhG8,15214
|
|
13
14
|
xitzin/tasks.py,sha256=_smEXy-THge8wmQqWDtX3iUmAmoHniw_qqZBlKjCdqA,4597
|
|
14
15
|
xitzin/templating.py,sha256=spjxb05wgwKA4txOMbWJuwEVMaoLovo13_ukbXAcBDY,6040
|
|
15
16
|
xitzin/testing.py,sha256=wMiToopD2TAqTsreVqOrIUNCayORNQQennxR_GwMBSY,10980
|
|
16
|
-
xitzin-0.
|
|
17
|
-
xitzin-0.
|
|
18
|
-
xitzin-0.
|
|
17
|
+
xitzin-0.6.0.dist-info/WHEEL,sha256=5DEXXimM34_d4Gx1AuF9ysMr1_maoEtGKjaILM3s4w4,80
|
|
18
|
+
xitzin-0.6.0.dist-info/METADATA,sha256=yMpMV5_cxjYK26sDgN70-CX4Z9nDnVxCu0oHjfsYxPc,3456
|
|
19
|
+
xitzin-0.6.0.dist-info/RECORD,,
|