xitzin 0.1.2__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 +78 -0
- xitzin/application.py +548 -0
- xitzin/auth.py +152 -0
- xitzin/cgi.py +555 -0
- xitzin/exceptions.py +138 -0
- xitzin/middleware.py +219 -0
- xitzin/py.typed +0 -0
- xitzin/requests.py +150 -0
- xitzin/responses.py +235 -0
- xitzin/routing.py +381 -0
- xitzin/templating.py +222 -0
- xitzin/testing.py +267 -0
- xitzin-0.1.2.dist-info/METADATA +118 -0
- xitzin-0.1.2.dist-info/RECORD +15 -0
- xitzin-0.1.2.dist-info/WHEEL +4 -0
xitzin/auth.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Certificate authentication helpers.
|
|
2
|
+
|
|
3
|
+
This module provides decorators and utilities for working with
|
|
4
|
+
Gemini client certificate authentication.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from functools import wraps
|
|
11
|
+
from typing import TYPE_CHECKING, Any, Callable
|
|
12
|
+
|
|
13
|
+
from .exceptions import CertificateNotAuthorized, CertificateRequired
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from cryptography.x509 import Certificate
|
|
17
|
+
|
|
18
|
+
from .requests import Request
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class CertificateIdentity:
|
|
23
|
+
"""Represents a user identity based on their client certificate.
|
|
24
|
+
|
|
25
|
+
Example:
|
|
26
|
+
identity = get_identity(request)
|
|
27
|
+
if identity:
|
|
28
|
+
print(f"User: {identity.short_id}")
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
fingerprint: str
|
|
32
|
+
"""SHA-256 fingerprint of the certificate."""
|
|
33
|
+
|
|
34
|
+
cert: "Certificate | None" = None
|
|
35
|
+
"""The raw certificate object (if available)."""
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def short_id(self) -> str:
|
|
39
|
+
"""Short identifier suitable for display (first 16 chars)."""
|
|
40
|
+
return self.fingerprint[:16]
|
|
41
|
+
|
|
42
|
+
def __str__(self) -> str:
|
|
43
|
+
return f"CertIdentity({self.short_id}...)"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def get_identity(request: "Request") -> CertificateIdentity | None:
|
|
47
|
+
"""Get the certificate-based identity from a request.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
request: The current request.
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
CertificateIdentity if a client certificate was provided, None otherwise.
|
|
54
|
+
|
|
55
|
+
Example:
|
|
56
|
+
@app.gemini("/whoami")
|
|
57
|
+
def whoami(request: Request):
|
|
58
|
+
identity = get_identity(request)
|
|
59
|
+
if identity:
|
|
60
|
+
return f"# Your ID: {identity.short_id}"
|
|
61
|
+
return "# You are anonymous"
|
|
62
|
+
"""
|
|
63
|
+
if request.client_cert_fingerprint:
|
|
64
|
+
return CertificateIdentity(
|
|
65
|
+
fingerprint=request.client_cert_fingerprint,
|
|
66
|
+
cert=request.client_cert,
|
|
67
|
+
)
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def require_certificate(handler: Callable[..., Any]) -> Callable[..., Any]:
|
|
72
|
+
"""Decorator that requires a valid client certificate.
|
|
73
|
+
|
|
74
|
+
If no certificate is provided, returns status 60 (certificate required).
|
|
75
|
+
|
|
76
|
+
Example:
|
|
77
|
+
@app.gemini("/admin")
|
|
78
|
+
@require_certificate
|
|
79
|
+
def admin_panel(request: Request):
|
|
80
|
+
return "# Admin Panel"
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
@wraps(handler)
|
|
84
|
+
def wrapper(request: "Request", *args: Any, **kwargs: Any) -> Any:
|
|
85
|
+
if not request.client_cert_fingerprint:
|
|
86
|
+
raise CertificateRequired("Client certificate required")
|
|
87
|
+
return handler(request, *args, **kwargs)
|
|
88
|
+
|
|
89
|
+
return wrapper
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def require_fingerprint(
|
|
93
|
+
*allowed_fingerprints: str,
|
|
94
|
+
) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
|
|
95
|
+
"""Decorator factory that requires specific certificate fingerprints.
|
|
96
|
+
|
|
97
|
+
If the client certificate fingerprint is not in the allowed list,
|
|
98
|
+
returns status 61 (certificate not authorized).
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
*allowed_fingerprints: SHA-256 fingerprints that are allowed.
|
|
102
|
+
|
|
103
|
+
Example:
|
|
104
|
+
ADMIN_CERTS = [
|
|
105
|
+
"abc123...", # Alice's certificate
|
|
106
|
+
"def456...", # Bob's certificate
|
|
107
|
+
]
|
|
108
|
+
|
|
109
|
+
@app.gemini("/admin")
|
|
110
|
+
@require_fingerprint(*ADMIN_CERTS)
|
|
111
|
+
def admin_panel(request: Request):
|
|
112
|
+
return "# Admin Panel"
|
|
113
|
+
"""
|
|
114
|
+
allowed_set = set(allowed_fingerprints)
|
|
115
|
+
|
|
116
|
+
def decorator(handler: Callable[..., Any]) -> Callable[..., Any]:
|
|
117
|
+
@wraps(handler)
|
|
118
|
+
def wrapper(request: "Request", *args: Any, **kwargs: Any) -> Any:
|
|
119
|
+
if not request.client_cert_fingerprint:
|
|
120
|
+
raise CertificateRequired("Client certificate required")
|
|
121
|
+
|
|
122
|
+
if request.client_cert_fingerprint not in allowed_set:
|
|
123
|
+
raise CertificateNotAuthorized("Certificate not authorized")
|
|
124
|
+
|
|
125
|
+
return handler(request, *args, **kwargs)
|
|
126
|
+
|
|
127
|
+
return wrapper
|
|
128
|
+
|
|
129
|
+
return decorator
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def optional_certificate(handler: Callable[..., Any]) -> Callable[..., Any]:
|
|
133
|
+
"""Decorator that makes certificate identity available but not required.
|
|
134
|
+
|
|
135
|
+
Sets request.state.identity to CertificateIdentity or None.
|
|
136
|
+
|
|
137
|
+
Example:
|
|
138
|
+
@app.gemini("/profile")
|
|
139
|
+
@optional_certificate
|
|
140
|
+
def profile(request: Request):
|
|
141
|
+
identity = request.state.identity
|
|
142
|
+
if identity:
|
|
143
|
+
return f"# Welcome back, {identity.short_id}"
|
|
144
|
+
return "# Welcome, anonymous visitor"
|
|
145
|
+
"""
|
|
146
|
+
|
|
147
|
+
@wraps(handler)
|
|
148
|
+
def wrapper(request: "Request", *args: Any, **kwargs: Any) -> Any:
|
|
149
|
+
request.state.identity = get_identity(request)
|
|
150
|
+
return handler(request, *args, **kwargs)
|
|
151
|
+
|
|
152
|
+
return wrapper
|
xitzin/cgi.py
ADDED
|
@@ -0,0 +1,555 @@
|
|
|
1
|
+
"""CGI support for Xitzin applications.
|
|
2
|
+
|
|
3
|
+
This module provides CGI script execution capabilities for Xitzin,
|
|
4
|
+
following the Gemini CGI specification conventions established by
|
|
5
|
+
servers like Jetforce.
|
|
6
|
+
|
|
7
|
+
Example:
|
|
8
|
+
from xitzin import Xitzin
|
|
9
|
+
from xitzin.cgi import CGIHandler, CGIConfig
|
|
10
|
+
|
|
11
|
+
app = Xitzin()
|
|
12
|
+
|
|
13
|
+
# Mount a CGI directory
|
|
14
|
+
config = CGIConfig(timeout=30)
|
|
15
|
+
app.mount("/cgi-bin", CGIHandler("/srv/cgi-bin", config=config))
|
|
16
|
+
|
|
17
|
+
# Or mount a single script
|
|
18
|
+
app.mount("/calculator", CGIScript("/srv/scripts/calc.py"))
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import asyncio
|
|
24
|
+
import os
|
|
25
|
+
from dataclasses import dataclass, field
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import TYPE_CHECKING
|
|
28
|
+
|
|
29
|
+
from nauyaca.protocol.response import GeminiResponse
|
|
30
|
+
|
|
31
|
+
from .exceptions import BadRequest, CGIError, NotFound
|
|
32
|
+
|
|
33
|
+
if TYPE_CHECKING:
|
|
34
|
+
from .requests import Request
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# CGI protocol constants
|
|
38
|
+
GATEWAY_INTERFACE = "CGI/1.1"
|
|
39
|
+
SERVER_PROTOCOL = "GEMINI"
|
|
40
|
+
SERVER_SOFTWARE = "Xitzin/0.1.0"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class CGIConfig:
|
|
45
|
+
"""Configuration for CGI script execution.
|
|
46
|
+
|
|
47
|
+
Attributes:
|
|
48
|
+
timeout: Maximum execution time in seconds.
|
|
49
|
+
max_header_size: Maximum size of the status line in bytes.
|
|
50
|
+
streaming: Enable streaming mode for large responses.
|
|
51
|
+
check_execute_permission: Whether to verify execute permission.
|
|
52
|
+
inherit_environment: Whether to inherit parent environment variables.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
timeout: float = 30.0
|
|
56
|
+
max_header_size: int = 8192
|
|
57
|
+
streaming: bool = False
|
|
58
|
+
check_execute_permission: bool = True
|
|
59
|
+
inherit_environment: bool = True
|
|
60
|
+
app_state_keys: list[str] = field(default_factory=list)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass
|
|
64
|
+
class CGIResponse:
|
|
65
|
+
"""Parsed response from a CGI script.
|
|
66
|
+
|
|
67
|
+
Attributes:
|
|
68
|
+
status: Gemini status code (10-62).
|
|
69
|
+
meta: Status meta field (MIME type, prompt, URL, or error message).
|
|
70
|
+
body: Response body content, if any.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
status: int
|
|
74
|
+
meta: str
|
|
75
|
+
body: str | None = None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def build_cgi_env(
|
|
79
|
+
request: Request,
|
|
80
|
+
script_name: str,
|
|
81
|
+
path_info: str,
|
|
82
|
+
*,
|
|
83
|
+
app_state_vars: dict[str, str] | None = None,
|
|
84
|
+
inherit_environment: bool = True,
|
|
85
|
+
) -> dict[str, str]:
|
|
86
|
+
"""Build CGI environment variables from a request.
|
|
87
|
+
|
|
88
|
+
This follows the Gemini CGI conventions established by Jetforce
|
|
89
|
+
and other Gemini servers, based on RFC 3875.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
request: The Gemini request.
|
|
93
|
+
script_name: Name/path of the CGI script.
|
|
94
|
+
path_info: Additional path info after the script name.
|
|
95
|
+
app_state_vars: Application state variables to pass as XITZIN_*.
|
|
96
|
+
inherit_environment: Whether to inherit current environment.
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
Dictionary of environment variables for the CGI script.
|
|
100
|
+
"""
|
|
101
|
+
if inherit_environment:
|
|
102
|
+
env = os.environ.copy()
|
|
103
|
+
else:
|
|
104
|
+
env = {}
|
|
105
|
+
|
|
106
|
+
# Standard CGI variables (RFC 3875)
|
|
107
|
+
env["GATEWAY_INTERFACE"] = GATEWAY_INTERFACE
|
|
108
|
+
env["SERVER_PROTOCOL"] = SERVER_PROTOCOL
|
|
109
|
+
env["SERVER_SOFTWARE"] = SERVER_SOFTWARE
|
|
110
|
+
|
|
111
|
+
# Gemini-specific
|
|
112
|
+
env["GEMINI_URL"] = request.url
|
|
113
|
+
env["SCRIPT_NAME"] = script_name
|
|
114
|
+
env["PATH_INFO"] = path_info
|
|
115
|
+
env["QUERY_STRING"] = request.raw_query or ""
|
|
116
|
+
|
|
117
|
+
# Server information
|
|
118
|
+
env["SERVER_NAME"] = request.hostname
|
|
119
|
+
env["SERVER_PORT"] = str(request.port)
|
|
120
|
+
|
|
121
|
+
# Client information
|
|
122
|
+
if request.remote_addr:
|
|
123
|
+
env["REMOTE_ADDR"] = request.remote_addr
|
|
124
|
+
env["REMOTE_HOST"] = request.remote_addr # Could do reverse DNS
|
|
125
|
+
|
|
126
|
+
# TLS/Certificate information
|
|
127
|
+
if request.client_cert_fingerprint:
|
|
128
|
+
env["TLS_CLIENT_HASH"] = request.client_cert_fingerprint
|
|
129
|
+
env["TLS_CLIENT_AUTHORISED"] = "1"
|
|
130
|
+
env["AUTH_TYPE"] = "CERTIFICATE"
|
|
131
|
+
else:
|
|
132
|
+
env["TLS_CLIENT_AUTHORISED"] = "0"
|
|
133
|
+
|
|
134
|
+
# Application state as XITZIN_* variables
|
|
135
|
+
if app_state_vars:
|
|
136
|
+
for key, value in app_state_vars.items():
|
|
137
|
+
env[f"XITZIN_{key.upper()}"] = str(value)
|
|
138
|
+
|
|
139
|
+
return env
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def parse_cgi_output(stdout: bytes, stderr: bytes | None = None) -> CGIResponse:
|
|
143
|
+
"""Parse CGI script output into a structured response.
|
|
144
|
+
|
|
145
|
+
The expected format is:
|
|
146
|
+
<STATUS><SPACE><META>\\r\\n
|
|
147
|
+
[optional body]
|
|
148
|
+
|
|
149
|
+
Or for backwards compatibility:
|
|
150
|
+
<META>\\r\\n
|
|
151
|
+
[body] # Assumes status 20
|
|
152
|
+
|
|
153
|
+
Args:
|
|
154
|
+
stdout: The script's standard output.
|
|
155
|
+
stderr: The script's standard error (for error messages).
|
|
156
|
+
|
|
157
|
+
Returns:
|
|
158
|
+
Parsed CGI response.
|
|
159
|
+
|
|
160
|
+
Raises:
|
|
161
|
+
CGIError: If the output format is invalid.
|
|
162
|
+
"""
|
|
163
|
+
if not stdout:
|
|
164
|
+
raise CGIError("CGI script produced no output")
|
|
165
|
+
|
|
166
|
+
try:
|
|
167
|
+
output = stdout.decode("utf-8")
|
|
168
|
+
except UnicodeDecodeError:
|
|
169
|
+
output = stdout.decode("utf-8", errors="replace")
|
|
170
|
+
|
|
171
|
+
# Split header from body
|
|
172
|
+
if "\r\n" in output:
|
|
173
|
+
header, body = output.split("\r\n", 1)
|
|
174
|
+
elif "\n" in output:
|
|
175
|
+
header, body = output.split("\n", 1)
|
|
176
|
+
else:
|
|
177
|
+
# No newline - treat entire output as header (no body)
|
|
178
|
+
header = output
|
|
179
|
+
body = ""
|
|
180
|
+
|
|
181
|
+
# Parse header line: "20 text/gemini" or just "text/gemini"
|
|
182
|
+
header = header.strip()
|
|
183
|
+
if not header:
|
|
184
|
+
raise CGIError("CGI script produced empty header")
|
|
185
|
+
|
|
186
|
+
parts = header.split(None, 1) # Split on first whitespace
|
|
187
|
+
|
|
188
|
+
if len(parts) == 2 and parts[0].isdigit():
|
|
189
|
+
# Format: "20 text/gemini"
|
|
190
|
+
status = int(parts[0])
|
|
191
|
+
meta = parts[1]
|
|
192
|
+
elif len(parts) == 1 and not parts[0][0].isdigit():
|
|
193
|
+
# Format: "text/gemini" (assume status 20)
|
|
194
|
+
status = 20
|
|
195
|
+
meta = parts[0]
|
|
196
|
+
elif len(parts) == 1 and parts[0].isdigit():
|
|
197
|
+
# Just a status code without meta
|
|
198
|
+
status = int(parts[0])
|
|
199
|
+
meta = "text/gemini" if status == 20 else ""
|
|
200
|
+
else:
|
|
201
|
+
raise CGIError(f"Invalid CGI header format: {header[:100]}")
|
|
202
|
+
|
|
203
|
+
# Validate status code
|
|
204
|
+
if not (10 <= status <= 69):
|
|
205
|
+
raise CGIError(f"Invalid CGI status code: {status}")
|
|
206
|
+
|
|
207
|
+
return CGIResponse(status=status, meta=meta, body=body if body else None)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
class CGIHandler:
|
|
211
|
+
"""Execute CGI scripts from a directory.
|
|
212
|
+
|
|
213
|
+
This handler executes scripts located in a specified directory,
|
|
214
|
+
with proper environment variable setup and security validation.
|
|
215
|
+
|
|
216
|
+
Example:
|
|
217
|
+
from xitzin.cgi import CGIHandler, CGIConfig
|
|
218
|
+
|
|
219
|
+
config = CGIConfig(timeout=30)
|
|
220
|
+
handler = CGIHandler("/srv/gemini/cgi-bin", config=config)
|
|
221
|
+
app.mount("/cgi-bin", handler)
|
|
222
|
+
|
|
223
|
+
# Requests to /cgi-bin/hello.py execute /srv/gemini/cgi-bin/hello.py
|
|
224
|
+
"""
|
|
225
|
+
|
|
226
|
+
def __init__(
|
|
227
|
+
self,
|
|
228
|
+
script_dir: Path | str,
|
|
229
|
+
*,
|
|
230
|
+
config: CGIConfig | None = None,
|
|
231
|
+
) -> None:
|
|
232
|
+
"""Create a CGI directory handler.
|
|
233
|
+
|
|
234
|
+
Args:
|
|
235
|
+
script_dir: Directory containing CGI scripts.
|
|
236
|
+
config: CGI execution configuration.
|
|
237
|
+
|
|
238
|
+
Raises:
|
|
239
|
+
ValueError: If script_dir doesn't exist or isn't a directory.
|
|
240
|
+
"""
|
|
241
|
+
self.script_dir = Path(script_dir).resolve()
|
|
242
|
+
self.config = config or CGIConfig()
|
|
243
|
+
|
|
244
|
+
if not self.script_dir.exists():
|
|
245
|
+
msg = f"CGI script directory not found: {script_dir}"
|
|
246
|
+
raise ValueError(msg)
|
|
247
|
+
if not self.script_dir.is_dir():
|
|
248
|
+
msg = f"CGI script path is not a directory: {script_dir}"
|
|
249
|
+
raise ValueError(msg)
|
|
250
|
+
|
|
251
|
+
async def __call__(self, request: Request, path_info: str) -> GeminiResponse:
|
|
252
|
+
"""Handle a request by executing the appropriate CGI script.
|
|
253
|
+
|
|
254
|
+
Args:
|
|
255
|
+
request: The Gemini request.
|
|
256
|
+
path_info: Path after the mount prefix (e.g., "/script.py").
|
|
257
|
+
|
|
258
|
+
Returns:
|
|
259
|
+
GeminiResponse from the CGI script.
|
|
260
|
+
|
|
261
|
+
Raises:
|
|
262
|
+
NotFound: If the script doesn't exist.
|
|
263
|
+
CGIError: If execution fails.
|
|
264
|
+
BadRequest: If path validation fails.
|
|
265
|
+
"""
|
|
266
|
+
# Extract script name and extra path info
|
|
267
|
+
# path_info is like "/script.py" or "/script.py/extra/path"
|
|
268
|
+
path_info = path_info.lstrip("/")
|
|
269
|
+
if not path_info:
|
|
270
|
+
raise NotFound("No CGI script specified")
|
|
271
|
+
|
|
272
|
+
parts = path_info.split("/", 1)
|
|
273
|
+
script_name = parts[0]
|
|
274
|
+
extra_path = "/" + parts[1] if len(parts) > 1 else ""
|
|
275
|
+
|
|
276
|
+
# Validate script name
|
|
277
|
+
if not script_name:
|
|
278
|
+
raise NotFound("No CGI script specified")
|
|
279
|
+
|
|
280
|
+
# Security: check for path traversal attempts
|
|
281
|
+
if ".." in script_name or script_name.startswith("/"):
|
|
282
|
+
raise BadRequest("Invalid script name")
|
|
283
|
+
|
|
284
|
+
# Resolve script path
|
|
285
|
+
script_path = (self.script_dir / script_name).resolve()
|
|
286
|
+
|
|
287
|
+
# Security: ensure script is within allowed directory
|
|
288
|
+
try:
|
|
289
|
+
script_path.relative_to(self.script_dir)
|
|
290
|
+
except ValueError:
|
|
291
|
+
raise BadRequest("Script path outside CGI directory") from None
|
|
292
|
+
|
|
293
|
+
# Check script exists
|
|
294
|
+
if not script_path.exists():
|
|
295
|
+
raise NotFound(f"CGI script not found: {script_name}")
|
|
296
|
+
|
|
297
|
+
if not script_path.is_file():
|
|
298
|
+
raise NotFound(f"CGI script is not a file: {script_name}")
|
|
299
|
+
|
|
300
|
+
# Check execute permission
|
|
301
|
+
if self.config.check_execute_permission:
|
|
302
|
+
if not os.access(script_path, os.X_OK):
|
|
303
|
+
raise CGIError(f"CGI script not executable: {script_name}")
|
|
304
|
+
|
|
305
|
+
# Build environment
|
|
306
|
+
app_state_vars = self._get_app_state_vars(request)
|
|
307
|
+
env = build_cgi_env(
|
|
308
|
+
request,
|
|
309
|
+
script_name=f"/{script_name}",
|
|
310
|
+
path_info=extra_path,
|
|
311
|
+
app_state_vars=app_state_vars,
|
|
312
|
+
inherit_environment=self.config.inherit_environment,
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
# Execute script
|
|
316
|
+
cgi_response = await self._execute_script(script_path, env)
|
|
317
|
+
|
|
318
|
+
return GeminiResponse(
|
|
319
|
+
status=cgi_response.status,
|
|
320
|
+
meta=cgi_response.meta,
|
|
321
|
+
body=cgi_response.body,
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
def _get_app_state_vars(self, request: Request) -> dict[str, str]:
|
|
325
|
+
"""Extract app state variables to pass to CGI scripts."""
|
|
326
|
+
if not self.config.app_state_keys:
|
|
327
|
+
return {}
|
|
328
|
+
|
|
329
|
+
result = {}
|
|
330
|
+
try:
|
|
331
|
+
app_state = request.app.state
|
|
332
|
+
for key in self.config.app_state_keys:
|
|
333
|
+
try:
|
|
334
|
+
value = getattr(app_state, key)
|
|
335
|
+
result[key] = str(value)
|
|
336
|
+
except AttributeError:
|
|
337
|
+
pass
|
|
338
|
+
except RuntimeError:
|
|
339
|
+
# Request not bound to app
|
|
340
|
+
pass
|
|
341
|
+
|
|
342
|
+
return result
|
|
343
|
+
|
|
344
|
+
async def _execute_script(
|
|
345
|
+
self, script_path: Path, env: dict[str, str]
|
|
346
|
+
) -> CGIResponse:
|
|
347
|
+
"""Execute a CGI script and return the parsed response.
|
|
348
|
+
|
|
349
|
+
Args:
|
|
350
|
+
script_path: Path to the script.
|
|
351
|
+
env: Environment variables.
|
|
352
|
+
|
|
353
|
+
Returns:
|
|
354
|
+
Parsed CGI response.
|
|
355
|
+
|
|
356
|
+
Raises:
|
|
357
|
+
CGIError: If execution fails or times out.
|
|
358
|
+
"""
|
|
359
|
+
try:
|
|
360
|
+
process = await asyncio.create_subprocess_exec(
|
|
361
|
+
str(script_path),
|
|
362
|
+
stdin=asyncio.subprocess.DEVNULL,
|
|
363
|
+
stdout=asyncio.subprocess.PIPE,
|
|
364
|
+
stderr=asyncio.subprocess.PIPE,
|
|
365
|
+
env=env,
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
try:
|
|
369
|
+
stdout, stderr = await asyncio.wait_for(
|
|
370
|
+
process.communicate(),
|
|
371
|
+
timeout=self.config.timeout,
|
|
372
|
+
)
|
|
373
|
+
except asyncio.TimeoutError:
|
|
374
|
+
process.kill()
|
|
375
|
+
await process.wait()
|
|
376
|
+
raise CGIError(
|
|
377
|
+
f"CGI script timeout after {self.config.timeout}s"
|
|
378
|
+
) from None
|
|
379
|
+
|
|
380
|
+
# Check exit code
|
|
381
|
+
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}"
|
|
386
|
+
)
|
|
387
|
+
raise CGIError(f"CGI script exited with code {process.returncode}")
|
|
388
|
+
|
|
389
|
+
# Check header size
|
|
390
|
+
first_line_end = stdout.find(b"\n")
|
|
391
|
+
if first_line_end > self.config.max_header_size:
|
|
392
|
+
raise CGIError("CGI header exceeds maximum size")
|
|
393
|
+
|
|
394
|
+
return parse_cgi_output(stdout, stderr)
|
|
395
|
+
|
|
396
|
+
except FileNotFoundError:
|
|
397
|
+
raise NotFound(f"CGI script not found: {script_path.name}") from None
|
|
398
|
+
except PermissionError:
|
|
399
|
+
raise CGIError(f"Permission denied: {script_path.name}") from None
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
class CGIScript:
|
|
403
|
+
"""Execute a single CGI script.
|
|
404
|
+
|
|
405
|
+
This handler executes a specific CGI script for all requests,
|
|
406
|
+
useful for mounting a single script at a specific path.
|
|
407
|
+
|
|
408
|
+
Example:
|
|
409
|
+
from xitzin.cgi import CGIScript
|
|
410
|
+
|
|
411
|
+
handler = CGIScript("/srv/scripts/calculator.py", timeout=10)
|
|
412
|
+
app.mount("/calculator", handler)
|
|
413
|
+
|
|
414
|
+
# All requests to /calculator execute /srv/scripts/calculator.py
|
|
415
|
+
"""
|
|
416
|
+
|
|
417
|
+
def __init__(
|
|
418
|
+
self,
|
|
419
|
+
script_path: Path | str,
|
|
420
|
+
*,
|
|
421
|
+
timeout: float = 30.0,
|
|
422
|
+
check_execute_permission: bool = True,
|
|
423
|
+
inherit_environment: bool = True,
|
|
424
|
+
app_state_keys: list[str] | None = None,
|
|
425
|
+
) -> None:
|
|
426
|
+
"""Create a single-script CGI handler.
|
|
427
|
+
|
|
428
|
+
Args:
|
|
429
|
+
script_path: Path to the CGI script.
|
|
430
|
+
timeout: Maximum execution time in seconds.
|
|
431
|
+
check_execute_permission: Whether to verify execute permission.
|
|
432
|
+
inherit_environment: Whether to inherit parent environment.
|
|
433
|
+
app_state_keys: App state keys to pass as XITZIN_* variables.
|
|
434
|
+
|
|
435
|
+
Raises:
|
|
436
|
+
ValueError: If script doesn't exist.
|
|
437
|
+
"""
|
|
438
|
+
self.script_path = Path(script_path).resolve()
|
|
439
|
+
self.config = CGIConfig(
|
|
440
|
+
timeout=timeout,
|
|
441
|
+
check_execute_permission=check_execute_permission,
|
|
442
|
+
inherit_environment=inherit_environment,
|
|
443
|
+
app_state_keys=app_state_keys or [],
|
|
444
|
+
)
|
|
445
|
+
|
|
446
|
+
if not self.script_path.exists():
|
|
447
|
+
msg = f"CGI script not found: {script_path}"
|
|
448
|
+
raise ValueError(msg)
|
|
449
|
+
if not self.script_path.is_file():
|
|
450
|
+
msg = f"CGI script path is not a file: {script_path}"
|
|
451
|
+
raise ValueError(msg)
|
|
452
|
+
|
|
453
|
+
async def __call__(self, request: Request, path_info: str = "") -> GeminiResponse:
|
|
454
|
+
"""Execute the CGI script for this request.
|
|
455
|
+
|
|
456
|
+
Args:
|
|
457
|
+
request: The Gemini request.
|
|
458
|
+
path_info: Additional path info (usually empty for single scripts).
|
|
459
|
+
|
|
460
|
+
Returns:
|
|
461
|
+
GeminiResponse from the CGI script.
|
|
462
|
+
|
|
463
|
+
Raises:
|
|
464
|
+
CGIError: If execution fails.
|
|
465
|
+
"""
|
|
466
|
+
# Check execute permission
|
|
467
|
+
if self.config.check_execute_permission:
|
|
468
|
+
if not os.access(self.script_path, os.X_OK):
|
|
469
|
+
raise CGIError(f"CGI script not executable: {self.script_path.name}")
|
|
470
|
+
|
|
471
|
+
# Build environment
|
|
472
|
+
app_state_vars = self._get_app_state_vars(request)
|
|
473
|
+
env = build_cgi_env(
|
|
474
|
+
request,
|
|
475
|
+
script_name=f"/{self.script_path.name}",
|
|
476
|
+
path_info=path_info,
|
|
477
|
+
app_state_vars=app_state_vars,
|
|
478
|
+
inherit_environment=self.config.inherit_environment,
|
|
479
|
+
)
|
|
480
|
+
|
|
481
|
+
# Execute script
|
|
482
|
+
cgi_response = await self._execute_script(env)
|
|
483
|
+
|
|
484
|
+
return GeminiResponse(
|
|
485
|
+
status=cgi_response.status,
|
|
486
|
+
meta=cgi_response.meta,
|
|
487
|
+
body=cgi_response.body,
|
|
488
|
+
)
|
|
489
|
+
|
|
490
|
+
def _get_app_state_vars(self, request: Request) -> dict[str, str]:
|
|
491
|
+
"""Extract app state variables to pass to CGI scripts."""
|
|
492
|
+
if not self.config.app_state_keys:
|
|
493
|
+
return {}
|
|
494
|
+
|
|
495
|
+
result = {}
|
|
496
|
+
try:
|
|
497
|
+
app_state = request.app.state
|
|
498
|
+
for key in self.config.app_state_keys:
|
|
499
|
+
try:
|
|
500
|
+
value = getattr(app_state, key)
|
|
501
|
+
result[key] = str(value)
|
|
502
|
+
except AttributeError:
|
|
503
|
+
pass
|
|
504
|
+
except RuntimeError:
|
|
505
|
+
pass
|
|
506
|
+
|
|
507
|
+
return result
|
|
508
|
+
|
|
509
|
+
async def _execute_script(self, env: dict[str, str]) -> CGIResponse:
|
|
510
|
+
"""Execute the CGI script and return the parsed response."""
|
|
511
|
+
try:
|
|
512
|
+
process = await asyncio.create_subprocess_exec(
|
|
513
|
+
str(self.script_path),
|
|
514
|
+
stdin=asyncio.subprocess.DEVNULL,
|
|
515
|
+
stdout=asyncio.subprocess.PIPE,
|
|
516
|
+
stderr=asyncio.subprocess.PIPE,
|
|
517
|
+
env=env,
|
|
518
|
+
)
|
|
519
|
+
|
|
520
|
+
try:
|
|
521
|
+
stdout, stderr = await asyncio.wait_for(
|
|
522
|
+
process.communicate(),
|
|
523
|
+
timeout=self.config.timeout,
|
|
524
|
+
)
|
|
525
|
+
except asyncio.TimeoutError:
|
|
526
|
+
process.kill()
|
|
527
|
+
await process.wait()
|
|
528
|
+
raise CGIError(
|
|
529
|
+
f"CGI script timeout after {self.config.timeout}s"
|
|
530
|
+
) from None
|
|
531
|
+
|
|
532
|
+
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}"
|
|
537
|
+
)
|
|
538
|
+
raise CGIError(f"CGI script exited with code {process.returncode}")
|
|
539
|
+
|
|
540
|
+
return parse_cgi_output(stdout, stderr)
|
|
541
|
+
|
|
542
|
+
except FileNotFoundError:
|
|
543
|
+
raise NotFound(f"CGI script not found: {self.script_path.name}") from None
|
|
544
|
+
except PermissionError:
|
|
545
|
+
raise CGIError(f"Permission denied: {self.script_path.name}") from None
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
__all__ = [
|
|
549
|
+
"CGIConfig",
|
|
550
|
+
"CGIHandler",
|
|
551
|
+
"CGIResponse",
|
|
552
|
+
"CGIScript",
|
|
553
|
+
"build_cgi_env",
|
|
554
|
+
"parse_cgi_output",
|
|
555
|
+
]
|