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 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
- try:
563
- # Check mounted routes first
564
- mount_match = self._router.match_mount(request.path)
565
- if mount_match is not None:
566
- mounted_route, path_info = mount_match
567
-
568
- # Build middleware chain for mounted handler
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
- # Apply middleware
574
- handler = call_mounted_handler
575
- for mw in reversed(self._middleware):
576
- handler = self._wrap_middleware(mw, handler)
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
- route, params = match
699
+ route, params = match
586
700
 
587
- # Handle input flow
588
- if route.input_prompt and not request.query:
589
- return Input(
590
- route.input_prompt, route.sensitive_input
591
- ).to_gemini_response()
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
- # Add query to params for input routes
594
- if route.input_prompt and request.query:
595
- params["query"] = request.query
707
+ # Add query to params for input routes
708
+ if route.input_prompt and req.query:
709
+ params["query"] = req.query
596
710
 
597
- # Build middleware chain
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
- # Apply middleware (in reverse order so first registered runs first)
603
- handler = call_handler
604
- for mw in reversed(self._middleware):
605
- handler = self._wrap_middleware(mw, handler)
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
- return await handler(request)
721
+ traceback.print_exc()
722
+ return GeminiResponse(
723
+ status=StatusCode.TEMPORARY_FAILURE,
724
+ meta="Internal server error",
725
+ )
608
726
 
609
- except GeminiException as e:
610
- return GeminiResponse(status=e.status_code, meta=e.message)
611
- except Exception:
612
- # Log the error and return a generic failure
613
- import traceback
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
- traceback.print_exc()
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
- try:
631
- # Match Titan route
632
- match = self._router.match_titan(request.path)
633
- if match is None:
634
- raise NotFound(f"No Titan route matches: {request.path}")
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
- route, params = match
752
+ route, params = match
637
753
 
638
- # Validate auth token if required
639
- if route.auth_tokens is not None:
640
- if not request.token or request.token not in route.auth_tokens:
641
- raise CertificateRequired("Valid authentication token required")
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
- # Build middleware chain
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
- # Apply middleware (in reverse order so first registered runs first)
649
- handler = call_handler
650
- for mw in reversed(self._middleware):
651
- handler = self._wrap_middleware(mw, handler)
763
+ except GeminiException as e:
764
+ return GeminiResponse(status=e.status_code, meta=e.message)
765
+ except Exception:
766
+ import traceback
652
767
 
653
- return await handler(request)
768
+ traceback.print_exc()
769
+ return GeminiResponse(
770
+ status=StatusCode.TEMPORARY_FAILURE,
771
+ meta="Internal server error",
772
+ )
654
773
 
655
- except GeminiException as e:
656
- return GeminiResponse(status=e.status_code, meta=e.message)
657
- except Exception:
658
- import traceback
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
- traceback.print_exc()
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,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: xitzin
3
- Version: 0.4.0
3
+ Version: 0.6.0
4
4
  Summary: A Gemini Application Framework
5
5
  Keywords: gemini,protocol,framework,async,geminispace
6
6
  Author: Alan Velasco
@@ -1,18 +1,19 @@
1
- xitzin/__init__.py,sha256=GMfAmzBJ6TMZJWfF38r1k0y-vswOdLw9xfWpPfYMcqc,1851
2
- xitzin/application.py,sha256=axL5G4lRz0KTNLsNQkCGxWD0MGDpIH6Xe9OKkxJlJw8,27851
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=dZhrbOJPCrrbMiwrbRzxElsC7UDrWc7lvnQ5c0xBHts,12542
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.4.0.dist-info/WHEEL,sha256=XV0cjMrO7zXhVAIyyc8aFf1VjZ33Fen4IiJk5zFlC3g,80
17
- xitzin-0.4.0.dist-info/METADATA,sha256=Zv-Rde7LWIcf_FQfclXu77BzGipWf6_n0fOXVzkvk0A,3456
18
- xitzin-0.4.0.dist-info/RECORD,,
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,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: uv 0.9.26
2
+ Generator: uv 0.9.29
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any