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/__init__.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Xitzin - A Gemini Application Framework.
|
|
2
|
+
|
|
3
|
+
Xitzin is a framework for building Gemini protocol applications.
|
|
4
|
+
It uses Nauyaca for Gemini protocol communication.
|
|
5
|
+
|
|
6
|
+
Example:
|
|
7
|
+
from xitzin import Xitzin, Request
|
|
8
|
+
|
|
9
|
+
app = Xitzin()
|
|
10
|
+
|
|
11
|
+
@app.gemini("/")
|
|
12
|
+
def home(request: Request):
|
|
13
|
+
return "# Welcome to Gemini!"
|
|
14
|
+
|
|
15
|
+
@app.gemini("/user/{username}")
|
|
16
|
+
def profile(request: Request, username: str):
|
|
17
|
+
return f"# {username}'s Profile"
|
|
18
|
+
|
|
19
|
+
if __name__ == "__main__":
|
|
20
|
+
app.run()
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from .application import Xitzin
|
|
24
|
+
from .cgi import CGIConfig, CGIHandler, CGIScript
|
|
25
|
+
from .exceptions import (
|
|
26
|
+
BadRequest,
|
|
27
|
+
CertificateNotAuthorized,
|
|
28
|
+
CertificateNotValid,
|
|
29
|
+
CertificateRequired,
|
|
30
|
+
CGIError,
|
|
31
|
+
GeminiException,
|
|
32
|
+
Gone,
|
|
33
|
+
InputRequired,
|
|
34
|
+
NotFound,
|
|
35
|
+
PermanentFailure,
|
|
36
|
+
ProxyError,
|
|
37
|
+
ProxyRequestRefused,
|
|
38
|
+
SensitiveInputRequired,
|
|
39
|
+
ServerUnavailable,
|
|
40
|
+
SlowDown,
|
|
41
|
+
TemporaryFailure,
|
|
42
|
+
)
|
|
43
|
+
from .requests import Request
|
|
44
|
+
from .responses import Input, Link, Redirect, Response
|
|
45
|
+
|
|
46
|
+
__all__ = [
|
|
47
|
+
# Main application
|
|
48
|
+
"Xitzin",
|
|
49
|
+
# Request/Response
|
|
50
|
+
"Request",
|
|
51
|
+
"Response",
|
|
52
|
+
"Input",
|
|
53
|
+
"Redirect",
|
|
54
|
+
"Link",
|
|
55
|
+
# CGI support
|
|
56
|
+
"CGIConfig",
|
|
57
|
+
"CGIHandler",
|
|
58
|
+
"CGIScript",
|
|
59
|
+
# Exceptions
|
|
60
|
+
"GeminiException",
|
|
61
|
+
"InputRequired",
|
|
62
|
+
"SensitiveInputRequired",
|
|
63
|
+
"TemporaryFailure",
|
|
64
|
+
"ServerUnavailable",
|
|
65
|
+
"CGIError",
|
|
66
|
+
"ProxyError",
|
|
67
|
+
"SlowDown",
|
|
68
|
+
"PermanentFailure",
|
|
69
|
+
"NotFound",
|
|
70
|
+
"Gone",
|
|
71
|
+
"ProxyRequestRefused",
|
|
72
|
+
"BadRequest",
|
|
73
|
+
"CertificateRequired",
|
|
74
|
+
"CertificateNotAuthorized",
|
|
75
|
+
"CertificateNotValid",
|
|
76
|
+
]
|
|
77
|
+
|
|
78
|
+
__version__ = "0.1.0"
|
xitzin/application.py
ADDED
|
@@ -0,0 +1,548 @@
|
|
|
1
|
+
"""Main Xitzin application class.
|
|
2
|
+
|
|
3
|
+
This module provides the Xitzin class, the main entry point for creating
|
|
4
|
+
Gemini applications.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import TYPE_CHECKING, Any, Callable
|
|
12
|
+
|
|
13
|
+
from nauyaca.protocol.request import GeminiRequest
|
|
14
|
+
from nauyaca.protocol.response import GeminiResponse
|
|
15
|
+
from nauyaca.protocol.status import StatusCode
|
|
16
|
+
|
|
17
|
+
from .exceptions import GeminiException, NotFound
|
|
18
|
+
from .requests import Request
|
|
19
|
+
from .responses import Input, Redirect, convert_response
|
|
20
|
+
from .routing import MountedRoute, Route, Router
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from .templating import TemplateEngine
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class AppState:
|
|
27
|
+
"""Application-level state storage.
|
|
28
|
+
|
|
29
|
+
Store shared resources like database connections here.
|
|
30
|
+
|
|
31
|
+
Example:
|
|
32
|
+
app.state.db = create_db_connection()
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __setattr__(self, name: str, value: Any) -> None:
|
|
36
|
+
self.__dict__[name] = value
|
|
37
|
+
|
|
38
|
+
def __getattr__(self, name: str) -> Any:
|
|
39
|
+
try:
|
|
40
|
+
return self.__dict__[name]
|
|
41
|
+
except KeyError:
|
|
42
|
+
raise AttributeError(f"'AppState' has no attribute '{name}'") from None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class Xitzin:
|
|
46
|
+
"""Gemini Application Framework.
|
|
47
|
+
|
|
48
|
+
Xitzin provides an interface for building Gemini applications.
|
|
49
|
+
|
|
50
|
+
Example:
|
|
51
|
+
app = Xitzin(title="My Capsule")
|
|
52
|
+
|
|
53
|
+
@app.gemini("/")
|
|
54
|
+
def homepage(request: Request):
|
|
55
|
+
return "# Welcome to my capsule!"
|
|
56
|
+
|
|
57
|
+
@app.gemini("/user/{username}")
|
|
58
|
+
def profile(request: Request, username: str):
|
|
59
|
+
return f"# {username}'s Profile"
|
|
60
|
+
|
|
61
|
+
if __name__ == "__main__":
|
|
62
|
+
app.run()
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
def __init__(
|
|
66
|
+
self,
|
|
67
|
+
*,
|
|
68
|
+
title: str = "Xitzin App",
|
|
69
|
+
version: str = "0.1.0",
|
|
70
|
+
templates_dir: Path | str | None = None,
|
|
71
|
+
) -> None:
|
|
72
|
+
"""Create a new Xitzin application.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
title: Application title (for documentation).
|
|
76
|
+
version: Application version.
|
|
77
|
+
templates_dir: Directory containing Gemtext templates.
|
|
78
|
+
"""
|
|
79
|
+
self.title = title
|
|
80
|
+
self.version = version
|
|
81
|
+
self._router = Router()
|
|
82
|
+
self._state = AppState()
|
|
83
|
+
self._templates: TemplateEngine | None = None
|
|
84
|
+
self._startup_handlers: list[Callable[[], Any]] = []
|
|
85
|
+
self._shutdown_handlers: list[Callable[[], Any]] = []
|
|
86
|
+
self._middleware: list[Callable[..., Any]] = []
|
|
87
|
+
|
|
88
|
+
if templates_dir:
|
|
89
|
+
self._init_templates(Path(templates_dir))
|
|
90
|
+
|
|
91
|
+
def _init_templates(self, templates_dir: Path) -> None:
|
|
92
|
+
"""Initialize the template engine."""
|
|
93
|
+
from .templating import TemplateEngine
|
|
94
|
+
|
|
95
|
+
self._templates = TemplateEngine(templates_dir, app=self)
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def state(self) -> AppState:
|
|
99
|
+
"""Application-level state storage."""
|
|
100
|
+
return self._state
|
|
101
|
+
|
|
102
|
+
def template(self, name: str, **context: Any) -> Any:
|
|
103
|
+
"""Render a template.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
name: Template filename (e.g., "page.gmi").
|
|
107
|
+
**context: Variables to pass to the template.
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
TemplateResponse that can be returned from handlers.
|
|
111
|
+
|
|
112
|
+
Raises:
|
|
113
|
+
RuntimeError: If no templates directory was configured.
|
|
114
|
+
"""
|
|
115
|
+
if self._templates is None:
|
|
116
|
+
msg = "No templates directory configured"
|
|
117
|
+
raise RuntimeError(msg)
|
|
118
|
+
return self._templates.render(name, **context)
|
|
119
|
+
|
|
120
|
+
def reverse(self, name: str, **params: Any) -> str:
|
|
121
|
+
"""Build URL for a named route.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
name: Route name.
|
|
125
|
+
**params: Path parameters.
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
URL path string.
|
|
129
|
+
|
|
130
|
+
Raises:
|
|
131
|
+
ValueError: If route name not found or parameters missing.
|
|
132
|
+
|
|
133
|
+
Example:
|
|
134
|
+
url = app.reverse("user_profile", username="alice")
|
|
135
|
+
# Returns "/user/alice"
|
|
136
|
+
"""
|
|
137
|
+
return self._router.reverse(name, **params)
|
|
138
|
+
|
|
139
|
+
def redirect(
|
|
140
|
+
self, name: str, *, permanent: bool = False, **params: Any
|
|
141
|
+
) -> Redirect:
|
|
142
|
+
"""Create a redirect to a named route.
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
name: Route name.
|
|
146
|
+
permanent: If True, use status 31 (permanent redirect).
|
|
147
|
+
**params: Path parameters.
|
|
148
|
+
|
|
149
|
+
Returns:
|
|
150
|
+
Redirect response object.
|
|
151
|
+
|
|
152
|
+
Example:
|
|
153
|
+
@app.gemini("/old-profile/{username}")
|
|
154
|
+
def old_profile(request: Request, username: str):
|
|
155
|
+
return app.redirect("user_profile", username=username, permanent=True)
|
|
156
|
+
"""
|
|
157
|
+
url = self.reverse(name, **params)
|
|
158
|
+
return Redirect(url, permanent=permanent)
|
|
159
|
+
|
|
160
|
+
def gemini(
|
|
161
|
+
self, path: str, *, name: str | None = None
|
|
162
|
+
) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
|
|
163
|
+
"""Register a route handler.
|
|
164
|
+
|
|
165
|
+
Args:
|
|
166
|
+
path: URL path pattern (e.g., "/user/{id}").
|
|
167
|
+
name: Optional route name for URL reversing. Defaults to function name.
|
|
168
|
+
|
|
169
|
+
Returns:
|
|
170
|
+
Decorator function.
|
|
171
|
+
|
|
172
|
+
Example:
|
|
173
|
+
@app.gemini("/")
|
|
174
|
+
def home(request: Request):
|
|
175
|
+
return "# Home"
|
|
176
|
+
|
|
177
|
+
@app.gemini("/user/{username}", name="user_profile")
|
|
178
|
+
def profile(request: Request, username: str):
|
|
179
|
+
return f"# {username}"
|
|
180
|
+
"""
|
|
181
|
+
|
|
182
|
+
def decorator(handler: Callable[..., Any]) -> Callable[..., Any]:
|
|
183
|
+
route = Route(path, handler, name=name)
|
|
184
|
+
self._router.add_route(route)
|
|
185
|
+
return handler
|
|
186
|
+
|
|
187
|
+
return decorator
|
|
188
|
+
|
|
189
|
+
def input(
|
|
190
|
+
self,
|
|
191
|
+
path: str,
|
|
192
|
+
*,
|
|
193
|
+
prompt: str,
|
|
194
|
+
sensitive: bool = False,
|
|
195
|
+
name: str | None = None,
|
|
196
|
+
) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
|
|
197
|
+
"""Register an input route (status 10/11 flow).
|
|
198
|
+
|
|
199
|
+
When a request arrives without a query string, the client is prompted
|
|
200
|
+
for input. When the request includes a query string, the handler is
|
|
201
|
+
called with the decoded input as the `query` parameter.
|
|
202
|
+
|
|
203
|
+
Args:
|
|
204
|
+
path: URL path pattern.
|
|
205
|
+
prompt: Prompt text shown to the user.
|
|
206
|
+
sensitive: If True, use status 11 (sensitive input).
|
|
207
|
+
name: Optional route name for URL reversing. Defaults to function name.
|
|
208
|
+
|
|
209
|
+
Returns:
|
|
210
|
+
Decorator function.
|
|
211
|
+
|
|
212
|
+
Example:
|
|
213
|
+
@app.input("/search", prompt="Enter search query:", name="search")
|
|
214
|
+
def search(request: Request, query: str):
|
|
215
|
+
return f"# Results for: {query}"
|
|
216
|
+
"""
|
|
217
|
+
|
|
218
|
+
def decorator(handler: Callable[..., Any]) -> Callable[..., Any]:
|
|
219
|
+
route = Route(
|
|
220
|
+
path, handler, name=name, input_prompt=prompt, sensitive_input=sensitive
|
|
221
|
+
)
|
|
222
|
+
self._router.add_route(route)
|
|
223
|
+
return handler
|
|
224
|
+
|
|
225
|
+
return decorator
|
|
226
|
+
|
|
227
|
+
def mount(
|
|
228
|
+
self,
|
|
229
|
+
path: str,
|
|
230
|
+
handler: Callable[..., Any],
|
|
231
|
+
*,
|
|
232
|
+
name: str | None = None,
|
|
233
|
+
) -> None:
|
|
234
|
+
"""Mount a handler at a path prefix.
|
|
235
|
+
|
|
236
|
+
Mounted handlers receive requests for any path starting with the prefix.
|
|
237
|
+
The handler receives (request, path_info) where path_info is the
|
|
238
|
+
remaining path after the mount prefix.
|
|
239
|
+
|
|
240
|
+
Args:
|
|
241
|
+
path: Mount point prefix (e.g., "/cgi-bin", "/api").
|
|
242
|
+
handler: Callable that takes (request, path_info) and returns a response.
|
|
243
|
+
name: Optional name for the mount.
|
|
244
|
+
|
|
245
|
+
Example:
|
|
246
|
+
from xitzin.cgi import CGIHandler
|
|
247
|
+
|
|
248
|
+
app.mount("/cgi-bin", CGIHandler(script_dir="./scripts"))
|
|
249
|
+
|
|
250
|
+
# Requests to /cgi-bin/hello.py will call:
|
|
251
|
+
# handler(request, path_info="/hello.py")
|
|
252
|
+
"""
|
|
253
|
+
mounted = MountedRoute(path, handler, name=name)
|
|
254
|
+
self._router.add_mounted_route(mounted)
|
|
255
|
+
|
|
256
|
+
def cgi(
|
|
257
|
+
self,
|
|
258
|
+
path: str,
|
|
259
|
+
script_dir: Path | str,
|
|
260
|
+
*,
|
|
261
|
+
name: str | None = None,
|
|
262
|
+
timeout: float = 30.0,
|
|
263
|
+
app_state_keys: list[str] | None = None,
|
|
264
|
+
) -> None:
|
|
265
|
+
"""Mount a CGI directory at a path prefix.
|
|
266
|
+
|
|
267
|
+
This is a convenience method that creates a CGIHandler and mounts it.
|
|
268
|
+
|
|
269
|
+
Args:
|
|
270
|
+
path: Mount point prefix (e.g., "/cgi-bin").
|
|
271
|
+
script_dir: Directory containing CGI scripts.
|
|
272
|
+
name: Optional name for the mount.
|
|
273
|
+
timeout: Maximum script execution time in seconds.
|
|
274
|
+
app_state_keys: App state keys to pass as XITZIN_* env vars.
|
|
275
|
+
|
|
276
|
+
Example:
|
|
277
|
+
app.cgi("/cgi-bin", "/srv/gemini/cgi-bin", timeout=30)
|
|
278
|
+
|
|
279
|
+
# Requests to /cgi-bin/hello.py execute:
|
|
280
|
+
# /srv/gemini/cgi-bin/hello.py
|
|
281
|
+
"""
|
|
282
|
+
from .cgi import CGIConfig, CGIHandler
|
|
283
|
+
|
|
284
|
+
config = CGIConfig(
|
|
285
|
+
timeout=timeout,
|
|
286
|
+
app_state_keys=app_state_keys or [],
|
|
287
|
+
)
|
|
288
|
+
handler = CGIHandler(script_dir, config=config)
|
|
289
|
+
self.mount(path, handler, name=name)
|
|
290
|
+
|
|
291
|
+
def on_startup(self, handler: Callable[[], Any]) -> Callable[[], Any]:
|
|
292
|
+
"""Register a startup event handler.
|
|
293
|
+
|
|
294
|
+
Args:
|
|
295
|
+
handler: Function to call on startup.
|
|
296
|
+
|
|
297
|
+
Example:
|
|
298
|
+
@app.on_startup
|
|
299
|
+
async def startup():
|
|
300
|
+
app.state.db = await create_db_pool()
|
|
301
|
+
"""
|
|
302
|
+
self._startup_handlers.append(handler)
|
|
303
|
+
return handler
|
|
304
|
+
|
|
305
|
+
def on_shutdown(self, handler: Callable[[], Any]) -> Callable[[], Any]:
|
|
306
|
+
"""Register a shutdown event handler.
|
|
307
|
+
|
|
308
|
+
Args:
|
|
309
|
+
handler: Function to call on shutdown.
|
|
310
|
+
|
|
311
|
+
Example:
|
|
312
|
+
@app.on_shutdown
|
|
313
|
+
async def shutdown():
|
|
314
|
+
await app.state.db.close()
|
|
315
|
+
"""
|
|
316
|
+
self._shutdown_handlers.append(handler)
|
|
317
|
+
return handler
|
|
318
|
+
|
|
319
|
+
def middleware(self, handler: Callable[..., Any]) -> Callable[..., Any]:
|
|
320
|
+
"""Register middleware as a decorator.
|
|
321
|
+
|
|
322
|
+
Middleware receives (request, call_next) and must call call_next
|
|
323
|
+
to continue processing.
|
|
324
|
+
|
|
325
|
+
Args:
|
|
326
|
+
handler: Middleware function.
|
|
327
|
+
|
|
328
|
+
Example:
|
|
329
|
+
@app.middleware
|
|
330
|
+
async def log_requests(request: Request, call_next):
|
|
331
|
+
print(f"Request: {request.path}")
|
|
332
|
+
response = await call_next(request)
|
|
333
|
+
print(f"Response: {response.status}")
|
|
334
|
+
return response
|
|
335
|
+
"""
|
|
336
|
+
self._middleware.append(handler)
|
|
337
|
+
return handler
|
|
338
|
+
|
|
339
|
+
async def _run_startup(self) -> None:
|
|
340
|
+
"""Run all startup handlers."""
|
|
341
|
+
for handler in self._startup_handlers:
|
|
342
|
+
if asyncio.iscoroutinefunction(handler):
|
|
343
|
+
await handler()
|
|
344
|
+
else:
|
|
345
|
+
handler()
|
|
346
|
+
|
|
347
|
+
async def _run_shutdown(self) -> None:
|
|
348
|
+
"""Run all shutdown handlers in reverse order."""
|
|
349
|
+
for handler in reversed(self._shutdown_handlers):
|
|
350
|
+
if asyncio.iscoroutinefunction(handler):
|
|
351
|
+
await handler()
|
|
352
|
+
else:
|
|
353
|
+
handler()
|
|
354
|
+
|
|
355
|
+
async def _handle_request(self, raw_request: GeminiRequest) -> GeminiResponse:
|
|
356
|
+
"""Handle an incoming request.
|
|
357
|
+
|
|
358
|
+
This is the main request processing logic.
|
|
359
|
+
"""
|
|
360
|
+
request = Request(raw_request, self)
|
|
361
|
+
|
|
362
|
+
try:
|
|
363
|
+
# Check mounted routes first
|
|
364
|
+
mount_match = self._router.match_mount(request.path)
|
|
365
|
+
if mount_match is not None:
|
|
366
|
+
mounted_route, path_info = mount_match
|
|
367
|
+
|
|
368
|
+
# Build middleware chain for mounted handler
|
|
369
|
+
async def call_mounted_handler(req: Request) -> GeminiResponse:
|
|
370
|
+
result = await mounted_route.call_handler(req, path_info)
|
|
371
|
+
return convert_response(result, req)
|
|
372
|
+
|
|
373
|
+
# Apply middleware
|
|
374
|
+
handler = call_mounted_handler
|
|
375
|
+
for mw in reversed(self._middleware):
|
|
376
|
+
handler = self._wrap_middleware(mw, handler)
|
|
377
|
+
|
|
378
|
+
return await handler(request)
|
|
379
|
+
|
|
380
|
+
# Match regular route
|
|
381
|
+
match = self._router.match(request.path)
|
|
382
|
+
if match is None:
|
|
383
|
+
raise NotFound(f"No route matches: {request.path}")
|
|
384
|
+
|
|
385
|
+
route, params = match
|
|
386
|
+
|
|
387
|
+
# Handle input flow
|
|
388
|
+
if route.input_prompt and not request.query:
|
|
389
|
+
return Input(
|
|
390
|
+
route.input_prompt, route.sensitive_input
|
|
391
|
+
).to_gemini_response()
|
|
392
|
+
|
|
393
|
+
# Add query to params for input routes
|
|
394
|
+
if route.input_prompt and request.query:
|
|
395
|
+
params["query"] = request.query
|
|
396
|
+
|
|
397
|
+
# Build middleware chain
|
|
398
|
+
async def call_handler(req: Request) -> GeminiResponse:
|
|
399
|
+
result = await route.call_handler(req, params)
|
|
400
|
+
return convert_response(result, req)
|
|
401
|
+
|
|
402
|
+
# Apply middleware (in reverse order so first registered runs first)
|
|
403
|
+
handler = call_handler
|
|
404
|
+
for mw in reversed(self._middleware):
|
|
405
|
+
handler = self._wrap_middleware(mw, handler)
|
|
406
|
+
|
|
407
|
+
return await handler(request)
|
|
408
|
+
|
|
409
|
+
except GeminiException as e:
|
|
410
|
+
return GeminiResponse(status=e.status_code, meta=e.message)
|
|
411
|
+
except Exception as e:
|
|
412
|
+
# Log the error and return a generic failure
|
|
413
|
+
import traceback
|
|
414
|
+
|
|
415
|
+
traceback.print_exc()
|
|
416
|
+
return GeminiResponse(
|
|
417
|
+
status=StatusCode.TEMPORARY_FAILURE,
|
|
418
|
+
meta=f"Internal error: {type(e).__name__}",
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
def _wrap_middleware(
|
|
422
|
+
self,
|
|
423
|
+
middleware: Callable[..., Any],
|
|
424
|
+
next_handler: Callable[[Request], Any],
|
|
425
|
+
) -> Callable[[Request], Any]:
|
|
426
|
+
"""Wrap a handler with middleware."""
|
|
427
|
+
|
|
428
|
+
async def wrapped(request: Request) -> GeminiResponse:
|
|
429
|
+
if asyncio.iscoroutinefunction(middleware):
|
|
430
|
+
return await middleware(request, next_handler)
|
|
431
|
+
return middleware(request, next_handler)
|
|
432
|
+
|
|
433
|
+
return wrapped
|
|
434
|
+
|
|
435
|
+
def handle_request_sync(self, raw_request: GeminiRequest) -> GeminiResponse:
|
|
436
|
+
"""Handle a request synchronously (for testing)."""
|
|
437
|
+
return asyncio.get_event_loop().run_until_complete(
|
|
438
|
+
self._handle_request(raw_request)
|
|
439
|
+
)
|
|
440
|
+
|
|
441
|
+
async def run_async(
|
|
442
|
+
self,
|
|
443
|
+
host: str = "localhost",
|
|
444
|
+
port: int = 1965,
|
|
445
|
+
certfile: Path | str | None = None,
|
|
446
|
+
keyfile: Path | str | None = None,
|
|
447
|
+
) -> None:
|
|
448
|
+
"""Run the server asynchronously.
|
|
449
|
+
|
|
450
|
+
Args:
|
|
451
|
+
host: Host address to bind to.
|
|
452
|
+
port: Port to bind to.
|
|
453
|
+
certfile: Path to TLS certificate file.
|
|
454
|
+
keyfile: Path to TLS private key file.
|
|
455
|
+
"""
|
|
456
|
+
from nauyaca.server.protocol import GeminiServerProtocol
|
|
457
|
+
from nauyaca.server.tls_protocol import TLSServerProtocol
|
|
458
|
+
from nauyaca.security.certificates import generate_self_signed_cert
|
|
459
|
+
from nauyaca.security.pyopenssl_tls import create_pyopenssl_server_context
|
|
460
|
+
import tempfile
|
|
461
|
+
|
|
462
|
+
# Run startup handlers
|
|
463
|
+
await self._run_startup()
|
|
464
|
+
|
|
465
|
+
try:
|
|
466
|
+
# Create PyOpenSSL context (accepts any self-signed client cert)
|
|
467
|
+
if certfile and keyfile:
|
|
468
|
+
ssl_context = create_pyopenssl_server_context(
|
|
469
|
+
str(certfile),
|
|
470
|
+
str(keyfile),
|
|
471
|
+
request_client_cert=True,
|
|
472
|
+
)
|
|
473
|
+
else:
|
|
474
|
+
# Generate self-signed cert for development
|
|
475
|
+
cert_pem, key_pem = generate_self_signed_cert(
|
|
476
|
+
hostname="localhost",
|
|
477
|
+
key_size=2048,
|
|
478
|
+
valid_days=365,
|
|
479
|
+
)
|
|
480
|
+
|
|
481
|
+
with (
|
|
482
|
+
tempfile.NamedTemporaryFile(
|
|
483
|
+
suffix=".pem", delete=False, mode="wb"
|
|
484
|
+
) as cf,
|
|
485
|
+
tempfile.NamedTemporaryFile(
|
|
486
|
+
suffix=".key", delete=False, mode="wb"
|
|
487
|
+
) as kf,
|
|
488
|
+
):
|
|
489
|
+
cf.write(cert_pem)
|
|
490
|
+
kf.write(key_pem)
|
|
491
|
+
cf.flush()
|
|
492
|
+
kf.flush()
|
|
493
|
+
print("[Xitzin] Using self-signed certificate (development only)")
|
|
494
|
+
ssl_context = create_pyopenssl_server_context(
|
|
495
|
+
cf.name,
|
|
496
|
+
kf.name,
|
|
497
|
+
request_client_cert=True,
|
|
498
|
+
)
|
|
499
|
+
|
|
500
|
+
# Create handler that routes to our app
|
|
501
|
+
async def handle(request: GeminiRequest) -> GeminiResponse:
|
|
502
|
+
return await self._handle_request(request)
|
|
503
|
+
|
|
504
|
+
# Use TLSServerProtocol for manual TLS handling
|
|
505
|
+
# (supports self-signed client certs)
|
|
506
|
+
def create_protocol() -> TLSServerProtocol:
|
|
507
|
+
return TLSServerProtocol(
|
|
508
|
+
lambda: GeminiServerProtocol(handle, None), # type: ignore[arg-type]
|
|
509
|
+
ssl_context,
|
|
510
|
+
)
|
|
511
|
+
|
|
512
|
+
loop = asyncio.get_running_loop()
|
|
513
|
+
server = await loop.create_server(
|
|
514
|
+
create_protocol,
|
|
515
|
+
host,
|
|
516
|
+
port,
|
|
517
|
+
)
|
|
518
|
+
|
|
519
|
+
print(f"[Xitzin] {self.title} v{self.version}")
|
|
520
|
+
print(f"[Xitzin] Serving at gemini://{host}:{port}/")
|
|
521
|
+
|
|
522
|
+
async with server:
|
|
523
|
+
await server.serve_forever()
|
|
524
|
+
|
|
525
|
+
finally:
|
|
526
|
+
await self._run_shutdown()
|
|
527
|
+
|
|
528
|
+
def run(
|
|
529
|
+
self,
|
|
530
|
+
host: str = "localhost",
|
|
531
|
+
port: int = 1965,
|
|
532
|
+
certfile: Path | str | None = None,
|
|
533
|
+
keyfile: Path | str | None = None,
|
|
534
|
+
) -> None:
|
|
535
|
+
"""Run the server (blocking).
|
|
536
|
+
|
|
537
|
+
Args:
|
|
538
|
+
host: Host address to bind to.
|
|
539
|
+
port: Port to bind to.
|
|
540
|
+
certfile: Path to TLS certificate file.
|
|
541
|
+
keyfile: Path to TLS private key file.
|
|
542
|
+
"""
|
|
543
|
+
try:
|
|
544
|
+
asyncio.run(
|
|
545
|
+
self.run_async(host=host, port=port, certfile=certfile, keyfile=keyfile)
|
|
546
|
+
)
|
|
547
|
+
except KeyboardInterrupt:
|
|
548
|
+
print("\n[Xitzin] Shutting down...")
|