xitzin 0.2.0__tar.gz → 0.4.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {xitzin-0.2.0 → xitzin-0.4.0}/PKG-INFO +7 -4
- {xitzin-0.2.0 → xitzin-0.4.0}/pyproject.toml +6 -1
- {xitzin-0.2.0 → xitzin-0.4.0}/src/xitzin/__init__.py +9 -1
- {xitzin-0.2.0 → xitzin-0.4.0}/src/xitzin/application.py +278 -9
- {xitzin-0.2.0 → xitzin-0.4.0}/src/xitzin/auth.py +28 -2
- {xitzin-0.2.0 → xitzin-0.4.0}/src/xitzin/cgi.py +21 -10
- {xitzin-0.2.0 → xitzin-0.4.0}/src/xitzin/exceptions.py +18 -0
- {xitzin-0.2.0 → xitzin-0.4.0}/src/xitzin/middleware.py +171 -6
- {xitzin-0.2.0 → xitzin-0.4.0}/src/xitzin/requests.py +103 -0
- {xitzin-0.2.0 → xitzin-0.4.0}/src/xitzin/responses.py +2 -3
- {xitzin-0.2.0 → xitzin-0.4.0}/src/xitzin/routing.py +165 -0
- xitzin-0.4.0/src/xitzin/scgi.py +447 -0
- xitzin-0.4.0/src/xitzin/tasks.py +176 -0
- {xitzin-0.2.0 → xitzin-0.4.0}/src/xitzin/testing.py +111 -1
- {xitzin-0.2.0 → xitzin-0.4.0}/README.md +0 -0
- {xitzin-0.2.0 → xitzin-0.4.0}/src/xitzin/py.typed +0 -0
- {xitzin-0.2.0 → xitzin-0.4.0}/src/xitzin/sqlmodel.py +0 -0
- {xitzin-0.2.0 → xitzin-0.4.0}/src/xitzin/templating.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: xitzin
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: A Gemini Application Framework
|
|
5
5
|
Keywords: gemini,protocol,framework,async,geminispace
|
|
6
6
|
Author: Alan Velasco
|
|
@@ -20,15 +20,18 @@ Classifier: Typing :: Typed
|
|
|
20
20
|
Requires-Dist: jinja2>=3.1.0
|
|
21
21
|
Requires-Dist: nauyaca>=0.3.2
|
|
22
22
|
Requires-Dist: rich>=14.2.0
|
|
23
|
+
Requires-Dist: structlog>=25.5.0
|
|
23
24
|
Requires-Dist: typing-extensions>=4.15.0
|
|
24
25
|
Requires-Dist: sqlmodel>=0.0.22 ; extra == 'sqlmodel'
|
|
26
|
+
Requires-Dist: croniter>=1.0.0 ; extra == 'tasks'
|
|
25
27
|
Requires-Python: >=3.10
|
|
26
|
-
Project-URL: Changelog, https://xitzin.readthedocs.io/changelog/
|
|
27
|
-
Project-URL: Documentation, https://xitzin.readthedocs.io
|
|
28
28
|
Project-URL: Homepage, https://github.com/alanbato/xitzin
|
|
29
|
-
Project-URL:
|
|
29
|
+
Project-URL: Documentation, https://xitzin.readthedocs.io
|
|
30
30
|
Project-URL: Repository, https://github.com/alanbato/xitzin.git
|
|
31
|
+
Project-URL: Issues, https://github.com/alanbato/xitzin/issues
|
|
32
|
+
Project-URL: Changelog, https://xitzin.readthedocs.io/changelog/
|
|
31
33
|
Provides-Extra: sqlmodel
|
|
34
|
+
Provides-Extra: tasks
|
|
32
35
|
Description-Content-Type: text/markdown
|
|
33
36
|
|
|
34
37
|
# Xitzin
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "xitzin"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.4.0"
|
|
4
4
|
description = "A Gemini Application Framework"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = { text = "MIT" }
|
|
@@ -26,6 +26,7 @@ dependencies = [
|
|
|
26
26
|
"jinja2>=3.1.0",
|
|
27
27
|
"nauyaca>=0.3.2",
|
|
28
28
|
"rich>=14.2.0",
|
|
29
|
+
"structlog>=25.5.0",
|
|
29
30
|
"typing-extensions>=4.15.0",
|
|
30
31
|
]
|
|
31
32
|
|
|
@@ -33,6 +34,9 @@ dependencies = [
|
|
|
33
34
|
sqlmodel = [
|
|
34
35
|
"sqlmodel>=0.0.22",
|
|
35
36
|
]
|
|
37
|
+
tasks = [
|
|
38
|
+
"croniter>=1.0.0",
|
|
39
|
+
]
|
|
36
40
|
|
|
37
41
|
[project.urls]
|
|
38
42
|
Homepage = "https://github.com/alanbato/xitzin"
|
|
@@ -47,6 +51,7 @@ build-backend = "uv_build"
|
|
|
47
51
|
|
|
48
52
|
[dependency-groups]
|
|
49
53
|
dev = [
|
|
54
|
+
"croniter>=1.0.0",
|
|
50
55
|
"pre-commit>=4.5.1",
|
|
51
56
|
"pytest>=9.0.2",
|
|
52
57
|
"pytest-asyncio>=0.24.0",
|
|
@@ -22,6 +22,7 @@ Example:
|
|
|
22
22
|
|
|
23
23
|
from .application import Xitzin
|
|
24
24
|
from .cgi import CGIConfig, CGIHandler, CGIScript
|
|
25
|
+
from .scgi import SCGIApp, SCGIConfig, SCGIHandler
|
|
25
26
|
from .exceptions import (
|
|
26
27
|
BadRequest,
|
|
27
28
|
CertificateNotAuthorized,
|
|
@@ -38,9 +39,10 @@ from .exceptions import (
|
|
|
38
39
|
SensitiveInputRequired,
|
|
39
40
|
ServerUnavailable,
|
|
40
41
|
SlowDown,
|
|
42
|
+
TaskConfigurationError,
|
|
41
43
|
TemporaryFailure,
|
|
42
44
|
)
|
|
43
|
-
from .requests import Request
|
|
45
|
+
from .requests import Request, TitanRequest
|
|
44
46
|
from .responses import Input, Link, Redirect, Response
|
|
45
47
|
|
|
46
48
|
__all__ = [
|
|
@@ -48,6 +50,7 @@ __all__ = [
|
|
|
48
50
|
"Xitzin",
|
|
49
51
|
# Request/Response
|
|
50
52
|
"Request",
|
|
53
|
+
"TitanRequest",
|
|
51
54
|
"Response",
|
|
52
55
|
"Input",
|
|
53
56
|
"Redirect",
|
|
@@ -56,6 +59,10 @@ __all__ = [
|
|
|
56
59
|
"CGIConfig",
|
|
57
60
|
"CGIHandler",
|
|
58
61
|
"CGIScript",
|
|
62
|
+
# SCGI support
|
|
63
|
+
"SCGIApp",
|
|
64
|
+
"SCGIConfig",
|
|
65
|
+
"SCGIHandler",
|
|
59
66
|
# Exceptions
|
|
60
67
|
"GeminiException",
|
|
61
68
|
"InputRequired",
|
|
@@ -73,6 +80,7 @@ __all__ = [
|
|
|
73
80
|
"CertificateRequired",
|
|
74
81
|
"CertificateNotAuthorized",
|
|
75
82
|
"CertificateNotValid",
|
|
83
|
+
"TaskConfigurationError",
|
|
76
84
|
]
|
|
77
85
|
|
|
78
86
|
__version__ = "0.1.0"
|
|
@@ -11,15 +11,22 @@ from pathlib import Path
|
|
|
11
11
|
from typing import TYPE_CHECKING, Any, Callable
|
|
12
12
|
|
|
13
13
|
from nauyaca.protocol.request import GeminiRequest
|
|
14
|
+
from nauyaca.protocol.request import TitanRequest as NauyacaTitanRequest
|
|
14
15
|
from nauyaca.protocol.response import GeminiResponse
|
|
15
16
|
from nauyaca.protocol.status import StatusCode
|
|
16
17
|
|
|
17
|
-
from .exceptions import
|
|
18
|
-
|
|
18
|
+
from .exceptions import (
|
|
19
|
+
CertificateRequired,
|
|
20
|
+
GeminiException,
|
|
21
|
+
NotFound,
|
|
22
|
+
TaskConfigurationError,
|
|
23
|
+
)
|
|
24
|
+
from .requests import Request, TitanRequest
|
|
19
25
|
from .responses import Input, Redirect, convert_response
|
|
20
|
-
from .routing import MountedRoute, Route, Router
|
|
26
|
+
from .routing import MountedRoute, Route, Router, TitanRoute
|
|
21
27
|
|
|
22
28
|
if TYPE_CHECKING:
|
|
29
|
+
from .tasks import BackgroundTask
|
|
23
30
|
from .templating import TemplateEngine
|
|
24
31
|
|
|
25
32
|
|
|
@@ -84,6 +91,8 @@ class Xitzin:
|
|
|
84
91
|
self._startup_handlers: list[Callable[[], Any]] = []
|
|
85
92
|
self._shutdown_handlers: list[Callable[[], Any]] = []
|
|
86
93
|
self._middleware: list[Callable[..., Any]] = []
|
|
94
|
+
self._tasks: list[BackgroundTask] = []
|
|
95
|
+
self._task_handles: list[asyncio.Task[Any]] = []
|
|
87
96
|
|
|
88
97
|
if templates_dir:
|
|
89
98
|
self._init_templates(Path(templates_dir))
|
|
@@ -224,6 +233,45 @@ class Xitzin:
|
|
|
224
233
|
|
|
225
234
|
return decorator
|
|
226
235
|
|
|
236
|
+
def titan(
|
|
237
|
+
self,
|
|
238
|
+
path: str,
|
|
239
|
+
*,
|
|
240
|
+
name: str | None = None,
|
|
241
|
+
auth_tokens: list[str] | None = None,
|
|
242
|
+
) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
|
|
243
|
+
"""Register a Titan upload handler.
|
|
244
|
+
|
|
245
|
+
Titan is the upload companion protocol to Gemini. This decorator
|
|
246
|
+
registers a handler for Titan upload requests.
|
|
247
|
+
|
|
248
|
+
Args:
|
|
249
|
+
path: URL path pattern (e.g., "/upload/{filename}").
|
|
250
|
+
name: Optional route name. Defaults to function name.
|
|
251
|
+
auth_tokens: List of valid authentication tokens. If provided,
|
|
252
|
+
requests without a valid token are rejected with status 60.
|
|
253
|
+
|
|
254
|
+
Returns:
|
|
255
|
+
Decorator function.
|
|
256
|
+
|
|
257
|
+
Example:
|
|
258
|
+
@app.titan("/upload/{filename}", auth_tokens=["secret123"])
|
|
259
|
+
def upload(request: TitanRequest, content: bytes,
|
|
260
|
+
mime_type: str, token: str | None, filename: str):
|
|
261
|
+
if request.is_delete():
|
|
262
|
+
Path(f"./uploads/{filename}").unlink()
|
|
263
|
+
return "# Deleted"
|
|
264
|
+
Path(f"./uploads/{filename}").write_bytes(content)
|
|
265
|
+
return "# Upload successful"
|
|
266
|
+
"""
|
|
267
|
+
|
|
268
|
+
def decorator(handler: Callable[..., Any]) -> Callable[..., Any]:
|
|
269
|
+
route = TitanRoute(path, handler, name=name, auth_tokens=auth_tokens)
|
|
270
|
+
self._router.add_titan_route(route)
|
|
271
|
+
return handler
|
|
272
|
+
|
|
273
|
+
return decorator
|
|
274
|
+
|
|
227
275
|
def mount(
|
|
228
276
|
self,
|
|
229
277
|
path: str,
|
|
@@ -288,6 +336,69 @@ class Xitzin:
|
|
|
288
336
|
handler = CGIHandler(script_dir, config=config)
|
|
289
337
|
self.mount(path, handler, name=name)
|
|
290
338
|
|
|
339
|
+
def scgi(
|
|
340
|
+
self,
|
|
341
|
+
path: str,
|
|
342
|
+
host: str | None = None,
|
|
343
|
+
port: int | None = None,
|
|
344
|
+
socket_path: Path | str | None = None,
|
|
345
|
+
*,
|
|
346
|
+
name: str | None = None,
|
|
347
|
+
timeout: float = 30.0,
|
|
348
|
+
app_state_keys: list[str] | None = None,
|
|
349
|
+
) -> None:
|
|
350
|
+
"""Mount an SCGI backend at a path prefix.
|
|
351
|
+
|
|
352
|
+
This is a convenience method that creates an SCGIHandler or SCGIApp
|
|
353
|
+
and mounts it. Exactly one of (host+port) or socket_path must be provided.
|
|
354
|
+
|
|
355
|
+
Args:
|
|
356
|
+
path: Mount point prefix (e.g., "/dynamic").
|
|
357
|
+
host: SCGI server hostname (for TCP connection).
|
|
358
|
+
port: SCGI server port (for TCP connection).
|
|
359
|
+
socket_path: Path to Unix socket (for local connection).
|
|
360
|
+
name: Optional name for the mount.
|
|
361
|
+
timeout: Maximum response wait time in seconds.
|
|
362
|
+
app_state_keys: App state keys to pass as XITZIN_* env vars.
|
|
363
|
+
|
|
364
|
+
Raises:
|
|
365
|
+
ValueError: If neither or both connection types are specified.
|
|
366
|
+
|
|
367
|
+
Example:
|
|
368
|
+
# TCP connection
|
|
369
|
+
app.scgi("/dynamic", host="127.0.0.1", port=4000, timeout=30)
|
|
370
|
+
|
|
371
|
+
# Unix socket connection
|
|
372
|
+
app.scgi("/dynamic", socket_path="/tmp/scgi.sock", timeout=30)
|
|
373
|
+
"""
|
|
374
|
+
from .scgi import SCGIApp, SCGIConfig, SCGIHandler
|
|
375
|
+
|
|
376
|
+
# Validate parameters
|
|
377
|
+
tcp_specified = host is not None or port is not None
|
|
378
|
+
unix_specified = socket_path is not None
|
|
379
|
+
|
|
380
|
+
if tcp_specified and unix_specified:
|
|
381
|
+
msg = "Cannot specify both TCP (host/port) and Unix socket (socket_path)"
|
|
382
|
+
raise ValueError(msg)
|
|
383
|
+
if not tcp_specified and not unix_specified:
|
|
384
|
+
msg = "Must specify either TCP (host and port) or Unix socket (socket_path)"
|
|
385
|
+
raise ValueError(msg)
|
|
386
|
+
if tcp_specified and (host is None or port is None):
|
|
387
|
+
msg = "Both host and port must be specified for TCP connection"
|
|
388
|
+
raise ValueError(msg)
|
|
389
|
+
|
|
390
|
+
config = SCGIConfig(
|
|
391
|
+
timeout=timeout,
|
|
392
|
+
app_state_keys=app_state_keys or [],
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
if tcp_specified:
|
|
396
|
+
handler = SCGIHandler(host, port, config=config) # type: ignore[arg-type]
|
|
397
|
+
else:
|
|
398
|
+
handler = SCGIApp(socket_path, config=config) # type: ignore[arg-type]
|
|
399
|
+
|
|
400
|
+
self.mount(path, handler, name=name)
|
|
401
|
+
|
|
291
402
|
def on_startup(self, handler: Callable[[], Any]) -> Callable[[], Any]:
|
|
292
403
|
"""Register a startup event handler.
|
|
293
404
|
|
|
@@ -336,6 +447,75 @@ class Xitzin:
|
|
|
336
447
|
self._middleware.append(handler)
|
|
337
448
|
return handler
|
|
338
449
|
|
|
450
|
+
def task(
|
|
451
|
+
self,
|
|
452
|
+
*,
|
|
453
|
+
interval: str | int | float | None = None,
|
|
454
|
+
cron: str | None = None,
|
|
455
|
+
) -> Callable[[Callable[[], Any]], Callable[[], Any]]:
|
|
456
|
+
"""Register a background task.
|
|
457
|
+
|
|
458
|
+
Tasks run continuously while the server is running. They are started
|
|
459
|
+
after startup handlers and stopped before shutdown handlers.
|
|
460
|
+
|
|
461
|
+
Args:
|
|
462
|
+
interval: Run every N seconds (int) or duration string ("1h", "30m", "1d").
|
|
463
|
+
cron: Cron expression string ("0 * * * *" runs hourly).
|
|
464
|
+
Requires croniter: pip install 'xitzin[tasks]'
|
|
465
|
+
|
|
466
|
+
Exactly one of interval or cron must be provided.
|
|
467
|
+
|
|
468
|
+
Returns:
|
|
469
|
+
Decorator function.
|
|
470
|
+
|
|
471
|
+
Raises:
|
|
472
|
+
TaskConfigurationError: If neither or both parameters provided,
|
|
473
|
+
or if cron is used but croniter is not installed.
|
|
474
|
+
|
|
475
|
+
Example:
|
|
476
|
+
@app.task(interval="1h")
|
|
477
|
+
async def cleanup():
|
|
478
|
+
await app.state.db.cleanup_old_records()
|
|
479
|
+
|
|
480
|
+
@app.task(cron="0 2 * * *") # 2 AM daily
|
|
481
|
+
def backup():
|
|
482
|
+
backup_database()
|
|
483
|
+
"""
|
|
484
|
+
from .tasks import BackgroundTask, parse_interval
|
|
485
|
+
|
|
486
|
+
# Validate parameters
|
|
487
|
+
if interval is None and cron is None:
|
|
488
|
+
raise TaskConfigurationError("Either 'interval' or 'cron' must be provided")
|
|
489
|
+
if interval is not None and cron is not None:
|
|
490
|
+
raise TaskConfigurationError(
|
|
491
|
+
"Only one of 'interval' or 'cron' can be provided, not both"
|
|
492
|
+
)
|
|
493
|
+
|
|
494
|
+
# Check croniter availability
|
|
495
|
+
if cron is not None:
|
|
496
|
+
try:
|
|
497
|
+
from croniter import croniter as _ # noqa: F401
|
|
498
|
+
except ImportError:
|
|
499
|
+
raise TaskConfigurationError(
|
|
500
|
+
"croniter is required for cron tasks. "
|
|
501
|
+
"Install with: pip install 'xitzin[tasks]'"
|
|
502
|
+
) from None
|
|
503
|
+
|
|
504
|
+
def decorator(handler: Callable[[], Any]) -> Callable[[], Any]:
|
|
505
|
+
# Parse interval if provided
|
|
506
|
+
parsed_interval = parse_interval(interval) if interval else None
|
|
507
|
+
|
|
508
|
+
task = BackgroundTask(
|
|
509
|
+
handler=handler,
|
|
510
|
+
interval=parsed_interval,
|
|
511
|
+
cron=cron,
|
|
512
|
+
name=getattr(handler, "__name__", "<anonymous>"),
|
|
513
|
+
)
|
|
514
|
+
self._tasks.append(task)
|
|
515
|
+
return handler
|
|
516
|
+
|
|
517
|
+
return decorator
|
|
518
|
+
|
|
339
519
|
async def _run_startup(self) -> None:
|
|
340
520
|
"""Run all startup handlers."""
|
|
341
521
|
for handler in self._startup_handlers:
|
|
@@ -352,6 +532,26 @@ class Xitzin:
|
|
|
352
532
|
else:
|
|
353
533
|
handler()
|
|
354
534
|
|
|
535
|
+
async def _run_tasks(self) -> None:
|
|
536
|
+
"""Start all registered background tasks."""
|
|
537
|
+
from .tasks import run_cron_task, run_interval_task
|
|
538
|
+
|
|
539
|
+
for task in self._tasks:
|
|
540
|
+
if task.interval is not None:
|
|
541
|
+
handle = asyncio.create_task(run_interval_task(task))
|
|
542
|
+
else: # task.cron is not None
|
|
543
|
+
handle = asyncio.create_task(run_cron_task(task))
|
|
544
|
+
self._task_handles.append(handle)
|
|
545
|
+
|
|
546
|
+
async def _stop_tasks(self) -> None:
|
|
547
|
+
"""Stop all running background tasks."""
|
|
548
|
+
for handle in self._task_handles:
|
|
549
|
+
handle.cancel()
|
|
550
|
+
# Wait for all tasks to finish cancelling
|
|
551
|
+
if self._task_handles:
|
|
552
|
+
await asyncio.gather(*self._task_handles, return_exceptions=True)
|
|
553
|
+
self._task_handles.clear()
|
|
554
|
+
|
|
355
555
|
async def _handle_request(self, raw_request: GeminiRequest) -> GeminiResponse:
|
|
356
556
|
"""Handle an incoming request.
|
|
357
557
|
|
|
@@ -408,24 +608,69 @@ class Xitzin:
|
|
|
408
608
|
|
|
409
609
|
except GeminiException as e:
|
|
410
610
|
return GeminiResponse(status=e.status_code, meta=e.message)
|
|
411
|
-
except Exception
|
|
611
|
+
except Exception:
|
|
412
612
|
# Log the error and return a generic failure
|
|
413
613
|
import traceback
|
|
414
614
|
|
|
415
615
|
traceback.print_exc()
|
|
416
616
|
return GeminiResponse(
|
|
417
617
|
status=StatusCode.TEMPORARY_FAILURE,
|
|
418
|
-
meta=
|
|
618
|
+
meta="Internal server error",
|
|
619
|
+
)
|
|
620
|
+
|
|
621
|
+
async def _handle_titan_request(
|
|
622
|
+
self, raw_request: NauyacaTitanRequest
|
|
623
|
+
) -> GeminiResponse:
|
|
624
|
+
"""Handle an incoming Titan upload request.
|
|
625
|
+
|
|
626
|
+
This is the Titan request processing logic, separate from Gemini.
|
|
627
|
+
"""
|
|
628
|
+
request = TitanRequest(raw_request, self)
|
|
629
|
+
|
|
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}")
|
|
635
|
+
|
|
636
|
+
route, params = match
|
|
637
|
+
|
|
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")
|
|
642
|
+
|
|
643
|
+
# Build middleware chain
|
|
644
|
+
async def call_handler(req: TitanRequest) -> GeminiResponse:
|
|
645
|
+
result = await route.call_handler(req, params)
|
|
646
|
+
return convert_response(result, req)
|
|
647
|
+
|
|
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)
|
|
652
|
+
|
|
653
|
+
return await handler(request)
|
|
654
|
+
|
|
655
|
+
except GeminiException as e:
|
|
656
|
+
return GeminiResponse(status=e.status_code, meta=e.message)
|
|
657
|
+
except Exception:
|
|
658
|
+
import traceback
|
|
659
|
+
|
|
660
|
+
traceback.print_exc()
|
|
661
|
+
return GeminiResponse(
|
|
662
|
+
status=StatusCode.TEMPORARY_FAILURE,
|
|
663
|
+
meta="Internal server error",
|
|
419
664
|
)
|
|
420
665
|
|
|
421
666
|
def _wrap_middleware(
|
|
422
667
|
self,
|
|
423
668
|
middleware: Callable[..., Any],
|
|
424
|
-
next_handler: Callable[
|
|
425
|
-
) -> Callable[
|
|
669
|
+
next_handler: Callable[..., Any],
|
|
670
|
+
) -> Callable[..., Any]:
|
|
426
671
|
"""Wrap a handler with middleware."""
|
|
427
672
|
|
|
428
|
-
async def wrapped(request:
|
|
673
|
+
async def wrapped(request: Any) -> GeminiResponse:
|
|
429
674
|
if asyncio.iscoroutinefunction(middleware):
|
|
430
675
|
return await middleware(request, next_handler)
|
|
431
676
|
return middleware(request, next_handler)
|
|
@@ -462,6 +707,9 @@ class Xitzin:
|
|
|
462
707
|
# Run startup handlers
|
|
463
708
|
await self._run_startup()
|
|
464
709
|
|
|
710
|
+
# Start background tasks
|
|
711
|
+
await self._run_tasks()
|
|
712
|
+
|
|
465
713
|
try:
|
|
466
714
|
# Create PyOpenSSL context (accepts any self-signed client cert)
|
|
467
715
|
if certfile and keyfile:
|
|
@@ -501,11 +749,30 @@ class Xitzin:
|
|
|
501
749
|
async def handle(request: GeminiRequest) -> GeminiResponse:
|
|
502
750
|
return await self._handle_request(request)
|
|
503
751
|
|
|
752
|
+
# Create Titan upload handler if Titan routes are registered
|
|
753
|
+
upload_handler = None
|
|
754
|
+
if self._router.has_titan_routes():
|
|
755
|
+
from nauyaca.server.handler import UploadHandler
|
|
756
|
+
|
|
757
|
+
class XitzinUploadHandler(UploadHandler):
|
|
758
|
+
"""Wrapper to route Titan uploads to Xitzin handlers."""
|
|
759
|
+
|
|
760
|
+
def __init__(self, app: "Xitzin") -> None:
|
|
761
|
+
self._app = app
|
|
762
|
+
|
|
763
|
+
async def handle_upload(
|
|
764
|
+
self, request: NauyacaTitanRequest
|
|
765
|
+
) -> GeminiResponse:
|
|
766
|
+
return await self._app._handle_titan_request(request)
|
|
767
|
+
|
|
768
|
+
upload_handler = XitzinUploadHandler(self)
|
|
769
|
+
print("[Xitzin] Titan upload support enabled")
|
|
770
|
+
|
|
504
771
|
# Use TLSServerProtocol for manual TLS handling
|
|
505
772
|
# (supports self-signed client certs)
|
|
506
773
|
def create_protocol() -> TLSServerProtocol:
|
|
507
774
|
return TLSServerProtocol(
|
|
508
|
-
lambda: GeminiServerProtocol(handle, None),
|
|
775
|
+
lambda: GeminiServerProtocol(handle, None, upload_handler),
|
|
509
776
|
ssl_context,
|
|
510
777
|
)
|
|
511
778
|
|
|
@@ -523,6 +790,8 @@ class Xitzin:
|
|
|
523
790
|
await server.serve_forever()
|
|
524
791
|
|
|
525
792
|
finally:
|
|
793
|
+
# Stop background tasks
|
|
794
|
+
await self._stop_tasks()
|
|
526
795
|
await self._run_shutdown()
|
|
527
796
|
|
|
528
797
|
def run(
|
|
@@ -6,6 +6,7 @@ Gemini client certificate authentication.
|
|
|
6
6
|
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
|
|
9
|
+
import hmac
|
|
9
10
|
from dataclasses import dataclass
|
|
10
11
|
from functools import wraps
|
|
11
12
|
from typing import TYPE_CHECKING, Any, Callable
|
|
@@ -89,6 +90,27 @@ def require_certificate(handler: Callable[..., Any]) -> Callable[..., Any]:
|
|
|
89
90
|
return wrapper
|
|
90
91
|
|
|
91
92
|
|
|
93
|
+
def _timing_safe_fingerprint_check(fingerprint: str, allowed: list[str]) -> bool:
|
|
94
|
+
"""Check if fingerprint matches any allowed value using timing-safe comparison.
|
|
95
|
+
|
|
96
|
+
This prevents timing attacks that could reveal valid fingerprints.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
fingerprint: The fingerprint to check.
|
|
100
|
+
allowed: List of allowed fingerprints.
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
True if fingerprint matches any allowed value.
|
|
104
|
+
"""
|
|
105
|
+
# Encode once for comparison
|
|
106
|
+
fp_bytes = fingerprint.encode("utf-8")
|
|
107
|
+
|
|
108
|
+
for allowed_fp in allowed:
|
|
109
|
+
if hmac.compare_digest(fp_bytes, allowed_fp.encode("utf-8")):
|
|
110
|
+
return True
|
|
111
|
+
return False
|
|
112
|
+
|
|
113
|
+
|
|
92
114
|
def require_fingerprint(
|
|
93
115
|
*allowed_fingerprints: str,
|
|
94
116
|
) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
|
|
@@ -97,6 +119,8 @@ def require_fingerprint(
|
|
|
97
119
|
If the client certificate fingerprint is not in the allowed list,
|
|
98
120
|
returns status 61 (certificate not authorized).
|
|
99
121
|
|
|
122
|
+
Uses timing-safe comparison to prevent fingerprint enumeration attacks.
|
|
123
|
+
|
|
100
124
|
Args:
|
|
101
125
|
*allowed_fingerprints: SHA-256 fingerprints that are allowed.
|
|
102
126
|
|
|
@@ -111,7 +135,7 @@ def require_fingerprint(
|
|
|
111
135
|
def admin_panel(request: Request):
|
|
112
136
|
return "# Admin Panel"
|
|
113
137
|
"""
|
|
114
|
-
|
|
138
|
+
allowed_list = list(allowed_fingerprints)
|
|
115
139
|
|
|
116
140
|
def decorator(handler: Callable[..., Any]) -> Callable[..., Any]:
|
|
117
141
|
@wraps(handler)
|
|
@@ -119,7 +143,9 @@ def require_fingerprint(
|
|
|
119
143
|
if not request.client_cert_fingerprint:
|
|
120
144
|
raise CertificateRequired("Client certificate required")
|
|
121
145
|
|
|
122
|
-
if
|
|
146
|
+
if not _timing_safe_fingerprint_check(
|
|
147
|
+
request.client_cert_fingerprint, allowed_list
|
|
148
|
+
):
|
|
123
149
|
raise CertificateNotAuthorized("Certificate not authorized")
|
|
124
150
|
|
|
125
151
|
return handler(request, *args, **kwargs)
|
|
@@ -26,10 +26,13 @@ from dataclasses import dataclass, field
|
|
|
26
26
|
from pathlib import Path
|
|
27
27
|
from typing import TYPE_CHECKING
|
|
28
28
|
|
|
29
|
+
import structlog
|
|
29
30
|
from nauyaca.protocol.response import GeminiResponse
|
|
30
31
|
|
|
31
32
|
from .exceptions import BadRequest, CGIError, NotFound
|
|
32
33
|
|
|
34
|
+
logger = structlog.get_logger("xitzin.cgi")
|
|
35
|
+
|
|
33
36
|
if TYPE_CHECKING:
|
|
34
37
|
from .requests import Request
|
|
35
38
|
|
|
@@ -56,7 +59,7 @@ class CGIConfig:
|
|
|
56
59
|
max_header_size: int = 8192
|
|
57
60
|
streaming: bool = False
|
|
58
61
|
check_execute_permission: bool = True
|
|
59
|
-
inherit_environment: bool =
|
|
62
|
+
inherit_environment: bool = False
|
|
60
63
|
app_state_keys: list[str] = field(default_factory=list)
|
|
61
64
|
|
|
62
65
|
|
|
@@ -379,10 +382,14 @@ class CGIHandler:
|
|
|
379
382
|
|
|
380
383
|
# Check exit code
|
|
381
384
|
if process.returncode != 0:
|
|
382
|
-
|
|
383
|
-
if
|
|
384
|
-
|
|
385
|
-
|
|
385
|
+
# Log stderr server-side but don't expose to client
|
|
386
|
+
if stderr:
|
|
387
|
+
error_detail = stderr.decode("utf-8", errors="replace")[:500]
|
|
388
|
+
logger.error(
|
|
389
|
+
"cgi_script_failed",
|
|
390
|
+
script=str(script_path),
|
|
391
|
+
returncode=process.returncode,
|
|
392
|
+
stderr=error_detail,
|
|
386
393
|
)
|
|
387
394
|
raise CGIError(f"CGI script exited with code {process.returncode}")
|
|
388
395
|
|
|
@@ -420,7 +427,7 @@ class CGIScript:
|
|
|
420
427
|
*,
|
|
421
428
|
timeout: float = 30.0,
|
|
422
429
|
check_execute_permission: bool = True,
|
|
423
|
-
inherit_environment: bool =
|
|
430
|
+
inherit_environment: bool = False,
|
|
424
431
|
app_state_keys: list[str] | None = None,
|
|
425
432
|
) -> None:
|
|
426
433
|
"""Create a single-script CGI handler.
|
|
@@ -530,10 +537,14 @@ class CGIScript:
|
|
|
530
537
|
) from None
|
|
531
538
|
|
|
532
539
|
if process.returncode != 0:
|
|
533
|
-
|
|
534
|
-
if
|
|
535
|
-
|
|
536
|
-
|
|
540
|
+
# Log stderr server-side but don't expose to client
|
|
541
|
+
if stderr:
|
|
542
|
+
error_detail = stderr.decode("utf-8", errors="replace")[:500]
|
|
543
|
+
logger.error(
|
|
544
|
+
"cgi_script_failed",
|
|
545
|
+
script=str(self.script_path),
|
|
546
|
+
returncode=process.returncode,
|
|
547
|
+
stderr=error_detail,
|
|
537
548
|
)
|
|
538
549
|
raise CGIError(f"CGI script exited with code {process.returncode}")
|
|
539
550
|
|
|
@@ -136,3 +136,21 @@ class CertificateNotValid(GeminiException):
|
|
|
136
136
|
|
|
137
137
|
status_code = 62
|
|
138
138
|
default_message = "Certificate not valid"
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
# Application configuration errors
|
|
142
|
+
class TaskConfigurationError(Exception):
|
|
143
|
+
"""Raised when a background task is misconfigured.
|
|
144
|
+
|
|
145
|
+
This typically indicates mutually exclusive parameters were provided,
|
|
146
|
+
or a required optional dependency is missing.
|
|
147
|
+
|
|
148
|
+
Example:
|
|
149
|
+
@app.task() # Error: neither interval nor cron provided
|
|
150
|
+
def my_task():
|
|
151
|
+
pass
|
|
152
|
+
|
|
153
|
+
@app.task(interval="1h", cron="* * * * *") # Error: both provided
|
|
154
|
+
def my_task():
|
|
155
|
+
pass
|
|
156
|
+
"""
|