tina4-python 3.9.0__tar.gz → 3.9.2__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.
- {tina4_python-3.9.0 → tina4_python-3.9.2}/PKG-INFO +1 -1
- {tina4_python-3.9.0 → tina4_python-3.9.2}/pyproject.toml +1 -1
- {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/CLAUDE.md +7 -7
- {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/__init__.py +1 -1
- {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/auth/__init__.py +3 -3
- {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/cli/__init__.py +31 -0
- {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/core/middleware.py +126 -3
- {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/core/server.py +4 -3
- {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/frond/engine.py +20 -1
- {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/query_builder/__init__.py +146 -0
- {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/queue/__init__.py +232 -132
- {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/session/__init__.py +6 -1
- tina4_python-3.9.2/tina4_python/websocket/backplane.py +121 -0
- {tina4_python-3.9.0 → tina4_python-3.9.2}/.gitignore +0 -0
- {tina4_python-3.9.0 → tina4_python-3.9.2}/README.md +0 -0
- {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/HtmlElement.py +0 -0
- {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/Testing.py +0 -0
- {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/ai/__init__.py +0 -0
- {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/api/__init__.py +0 -0
- {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/cache/__init__.py +0 -0
- {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/container/__init__.py +0 -0
- {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/core/__init__.py +0 -0
- {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/core/cache.py +0 -0
- {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/core/constants.py +0 -0
- {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/core/events.py +0 -0
- {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/core/request.py +0 -0
- {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/core/response.py +0 -0
- {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/core/router.py +0 -0
- {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/crud/__init__.py +0 -0
- {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/database/__init__.py +0 -0
- {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/database/adapter.py +0 -0
- {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/database/connection.py +0 -0
- {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/database/firebird.py +0 -0
- {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/database/mssql.py +0 -0
- {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/database/mysql.py +0 -0
- {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/database/odbc.py +0 -0
- {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/database/postgres.py +0 -0
- {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/database/sqlite.py +0 -0
- {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/debug/__init__.py +0 -0
- {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/debug/error_overlay.py +0 -0
- {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/dev_admin/__init__.py +0 -0
- {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/dev_reload.py +0 -0
- {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/dotenv/__init__.py +0 -0
- {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/frond/FROND.md +0 -0
- {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/frond/__init__.py +0 -0
- {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/gallery/auth/meta.json +0 -0
- {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/gallery/auth/src/routes/api/gallery_auth.py +0 -0
- {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/gallery/database/meta.json +0 -0
- {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/gallery/database/src/routes/api/gallery_db.py +0 -0
- {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/gallery/error-overlay/meta.json +0 -0
- {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/gallery/error-overlay/src/routes/api/gallery_crash.py +0 -0
- {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/gallery/orm/meta.json +0 -0
- {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/gallery/orm/src/orm/Product.py +0 -0
- {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/gallery/orm/src/routes/api/gallery_products.py +0 -0
- {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/gallery/queue/meta.json +0 -0
- {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/gallery/queue/src/routes/api/gallery_queue.py +0 -0
- {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/gallery/rest-api/meta.json +0 -0
- {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/gallery/rest-api/src/routes/api/gallery_hello.py +0 -0
- {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/gallery/templates/meta.json +0 -0
- {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/gallery/templates/src/routes/gallery_page.py +0 -0
- {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/gallery/templates/src/templates/gallery_page.twig +0 -0
- {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/graphql/__init__.py +0 -0
- {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/i18n/__init__.py +0 -0
- {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/messenger/__init__.py +0 -0
- {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/migration/__init__.py +0 -0
- {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/migration/runner.py +0 -0
- {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/orm/__init__.py +0 -0
- {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/orm/fields.py +0 -0
- {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/orm/model.py +0 -0
- {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/public/css/tina4.css +0 -0
- {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/public/css/tina4.min.css +0 -0
- {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/public/favicon.ico +0 -0
- {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/public/images/logo.svg +0 -0
- {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/public/images/tina4-logo-icon.webp +0 -0
- {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/public/js/frond.min.js +0 -0
- {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/public/js/tina4-dev-admin.min.js +0 -0
- {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/public/js/tina4.min.js +0 -0
- {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/public/js/tina4js.min.js +0 -0
- {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/public/swagger/index.html +0 -0
- {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/public/swagger/oauth2-redirect.html +0 -0
- {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/queue_backends/__init__.py +0 -0
- {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/queue_backends/kafka_backend.py +0 -0
- {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/queue_backends/mongo_backend.py +0 -0
- {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/queue_backends/rabbitmq_backend.py +0 -0
- {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/scss/__init__.py +0 -0
- {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/scss/tina4css/_alerts.scss +0 -0
- {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/scss/tina4css/_badges.scss +0 -0
- {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/scss/tina4css/_buttons.scss +0 -0
- {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/scss/tina4css/_cards.scss +0 -0
- {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/scss/tina4css/_forms.scss +0 -0
- {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/scss/tina4css/_grid.scss +0 -0
- {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/scss/tina4css/_modals.scss +0 -0
- {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/scss/tina4css/_nav.scss +0 -0
- {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/scss/tina4css/_reset.scss +0 -0
- {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/scss/tina4css/_tables.scss +0 -0
- {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/scss/tina4css/_typography.scss +0 -0
- {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/scss/tina4css/_utilities.scss +0 -0
- {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/scss/tina4css/_variables.scss +0 -0
- {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/scss/tina4css/base.scss +0 -0
- {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/scss/tina4css/colors.scss +0 -0
- {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/scss/tina4css/tina4.scss +0 -0
- {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/seeder/__init__.py +0 -0
- {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/service/__init__.py +0 -0
- {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/session_handlers/__init__.py +0 -0
- {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/session_handlers/mongodb_handler.py +0 -0
- {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/session_handlers/redis_handler.py +0 -0
- {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/session_handlers/valkey_handler.py +0 -0
- {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/swagger/__init__.py +0 -0
- {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/templates/components/crud.twig +0 -0
- {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/templates/docker/distroless/Dockerfile +0 -0
- {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/templates/docker/poetry/Dockerfile +0 -0
- {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/templates/docker/python/Dockerfile +0 -0
- {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/templates/docker/uv/Dockerfile +0 -0
- {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/templates/errors/302.twig +0 -0
- {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/templates/errors/401.twig +0 -0
- {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/templates/errors/403.twig +0 -0
- {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/templates/errors/404.twig +0 -0
- {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/templates/errors/500.twig +0 -0
- {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/templates/errors/502.twig +0 -0
- {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/templates/errors/503.twig +0 -0
- {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/templates/errors/base.twig +0 -0
- {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/templates/frontend/README.md +0 -0
- {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/templates/readme.md +0 -0
- {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/test_client/__init__.py +0 -0
- {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/translations/af/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/translations/af/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/translations/en/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/translations/en/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/translations/es/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/translations/es/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/translations/fr/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/translations/fr/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/translations/ja/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/translations/ja/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/translations/zh/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/translations/zh/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/validator/__init__.py +0 -0
- {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/websocket/__init__.py +0 -0
- {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/wsdl/__init__.py +0 -0
|
@@ -480,7 +480,7 @@ Response.add_header("X-Custom", "value")
|
|
|
480
480
|
|
|
481
481
|
## Sessions
|
|
482
482
|
|
|
483
|
-
|
|
483
|
+
TINA4_TOKEN_LIMIT is used to set the session time, default 60 minutes
|
|
484
484
|
|
|
485
485
|
### Session Backends
|
|
486
486
|
|
|
@@ -1015,7 +1015,7 @@ async def admin_dashboard(request, response):
|
|
|
1015
1015
|
|
|
1016
1016
|
**Rule: Any operation that takes more than ~1 second must use a queue.**
|
|
1017
1017
|
|
|
1018
|
-
Supports:
|
|
1018
|
+
Supports: file (default, zero-config), RabbitMQ, Kafka, MongoDB.
|
|
1019
1019
|
|
|
1020
1020
|
### Producing — enqueue work from a route
|
|
1021
1021
|
|
|
@@ -1515,8 +1515,8 @@ Key `.env` settings:
|
|
|
1515
1515
|
```bash
|
|
1516
1516
|
# Authentication
|
|
1517
1517
|
SECRET=your-jwt-secret # JWT signing (default uses insecure placeholder)
|
|
1518
|
-
|
|
1519
|
-
|
|
1518
|
+
TINA4_API_KEY=your-api-key # Static bearer token for API auth (API_KEY fallback supported)
|
|
1519
|
+
TINA4_TOKEN_LIMIT=60 # Token lifetime in minutes (default: 60)
|
|
1520
1520
|
|
|
1521
1521
|
# Database
|
|
1522
1522
|
DATABASE_URL=sqlite:///app.db # Connection URL (driver://host:port/database)
|
|
@@ -1525,7 +1525,7 @@ DATABASE_PASSWORD= # DB password
|
|
|
1525
1525
|
|
|
1526
1526
|
# Framework
|
|
1527
1527
|
TINA4_DEBUG=true # Enable dev mode (toolbar, live reload, error overlay)
|
|
1528
|
-
TINA4_LOG_LEVEL=
|
|
1528
|
+
TINA4_LOG_LEVEL=ERROR # Log verbosity: ALL, DEBUG, INFO, WARNING, ERROR (default: ERROR)
|
|
1529
1529
|
TINA4_LOCALE=en # Language for framework messages (en, fr, af, zh, ja, es)
|
|
1530
1530
|
TINA4_DEFAULT_WEBSERVER=FALSE # Set to TRUE to use Tina4's built-in webserver instead of ASGI
|
|
1531
1531
|
HOST_NAME=localhost:7145
|
|
@@ -1534,7 +1534,7 @@ HOST_NAME=localhost:7145
|
|
|
1534
1534
|
TINA4_SESSION_HANDLER=SessionFileHandler # SessionFileHandler, SessionRedisHandler, SessionValkeyHandler, SessionMongoHandler
|
|
1535
1535
|
|
|
1536
1536
|
# Swagger/OpenAPI
|
|
1537
|
-
SWAGGER_TITLE=
|
|
1537
|
+
SWAGGER_TITLE=Tina4 API # API title (default: "Tina4 API")
|
|
1538
1538
|
SWAGGER_VERSION=1.0.0 # API version
|
|
1539
1539
|
SWAGGER_DESCRIPTION= # API description
|
|
1540
1540
|
SWAGGER_CONTACT_TEAM= # Contact name
|
|
@@ -1763,7 +1763,7 @@ async def dashboard(request, response):
|
|
|
1763
1763
|
- **`tina4python generate`**: model, route, migration, middleware scaffolding
|
|
1764
1764
|
- **Database**: 5 engines (SQLite, PostgreSQL, MySQL, MSSQL, Firebird), query caching (`TINA4_DB_CACHE=true`, `cache_stats()`, `cache_clear()`)
|
|
1765
1765
|
- **Sessions**: 4 backends (file, Redis/Valkey, MongoDB, database)
|
|
1766
|
-
- **Queue**:
|
|
1766
|
+
- **Queue**: file/RabbitMQ/Kafka/MongoDB backends, configured via env vars
|
|
1767
1767
|
- **Cache**: memory/Redis/file backends
|
|
1768
1768
|
- **Messenger**: .env driven SMTP/IMAP
|
|
1769
1769
|
- **ORM relationships**: `has_many`, `has_one`, `belongs_to` with eager loading (`include=`)
|
|
@@ -39,7 +39,7 @@ class Auth:
|
|
|
39
39
|
self.secret = secret or os.environ.get("SECRET", "tina4-default-secret")
|
|
40
40
|
self.algorithm = algorithm
|
|
41
41
|
self.expires_in = expires_in or int(
|
|
42
|
-
os.environ.get("
|
|
42
|
+
os.environ.get("TINA4_TOKEN_LIMIT", "60")
|
|
43
43
|
)
|
|
44
44
|
|
|
45
45
|
# ── JWT ────────────────────────────────────────────────────────
|
|
@@ -184,8 +184,8 @@ class Auth:
|
|
|
184
184
|
|
|
185
185
|
@staticmethod
|
|
186
186
|
def validate_api_key(provided: str) -> bool:
|
|
187
|
-
"""Check a Bearer token against the
|
|
188
|
-
expected = os.environ.get("API_KEY", "")
|
|
187
|
+
"""Check a Bearer token against the TINA4_API_KEY env var (falls back to API_KEY)."""
|
|
188
|
+
expected = os.environ.get("TINA4_API_KEY", os.environ.get("API_KEY", ""))
|
|
189
189
|
if not expected:
|
|
190
190
|
return False
|
|
191
191
|
return hmac.compare_digest(provided, expected)
|
|
@@ -141,6 +141,37 @@ def _init(args):
|
|
|
141
141
|
if not dst_file.exists() and src_file.exists():
|
|
142
142
|
dst_file.write_text(src_file.read_text(encoding="utf-8"), encoding="utf-8")
|
|
143
143
|
|
|
144
|
+
# Create root Dockerfile (uv variant) if it doesn't exist
|
|
145
|
+
root_dockerfile = target / "Dockerfile"
|
|
146
|
+
if not root_dockerfile.exists():
|
|
147
|
+
root_dockerfile.write_text(
|
|
148
|
+
'FROM python:3.13-slim AS build\n'
|
|
149
|
+
'WORKDIR /app\n'
|
|
150
|
+
'COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv\n'
|
|
151
|
+
'COPY pyproject.toml uv.lock* ./\n'
|
|
152
|
+
'RUN uv sync --frozen --no-dev\n'
|
|
153
|
+
'COPY . .\n'
|
|
154
|
+
'\n'
|
|
155
|
+
'FROM python:3.13-slim\n'
|
|
156
|
+
'WORKDIR /app\n'
|
|
157
|
+
'COPY --from=build /app .\n'
|
|
158
|
+
'COPY --from=build /usr/local/bin/uv /usr/local/bin/uv\n'
|
|
159
|
+
'ENV PATH="/app/.venv/bin:$PATH"\n'
|
|
160
|
+
'ENV HOST=0.0.0.0\n'
|
|
161
|
+
'ENV PORT=7145\n'
|
|
162
|
+
'EXPOSE 7145\n'
|
|
163
|
+
'CMD ["python", "app.py"]\n',
|
|
164
|
+
encoding="utf-8",
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
# Create root .dockerignore if it doesn't exist
|
|
168
|
+
root_dockerignore = target / ".dockerignore"
|
|
169
|
+
if not root_dockerignore.exists():
|
|
170
|
+
root_dockerignore.write_text(
|
|
171
|
+
".venv\n__pycache__\n.git\n.claude\n.env\n*.log\ntests\ntmp\n",
|
|
172
|
+
encoding="utf-8",
|
|
173
|
+
)
|
|
174
|
+
|
|
144
175
|
# Auto-detect AI tools and install context
|
|
145
176
|
from tina4_python.ai import detect_ai_names, install_context, install_all
|
|
146
177
|
detected = detect_ai_names(str(target))
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
# Tina4 Middleware — CORS
|
|
1
|
+
# Tina4 Middleware — CORS, Rate Limiter, CSRF.
|
|
2
2
|
"""
|
|
3
|
-
Built-in middleware for cross-origin requests and
|
|
3
|
+
Built-in middleware for cross-origin requests, rate limiting, and CSRF protection.
|
|
4
4
|
Zero dependencies — stdlib only.
|
|
5
5
|
|
|
6
|
-
from tina4_python.core.middleware import CorsMiddleware, RateLimiter
|
|
6
|
+
from tina4_python.core.middleware import CorsMiddleware, RateLimiter, CsrfMiddleware
|
|
7
7
|
|
|
8
8
|
CORS is configured via environment variables:
|
|
9
9
|
TINA4_CORS_ORIGINS=* # Allowed origins (* = all)
|
|
@@ -14,9 +14,13 @@ CORS is configured via environment variables:
|
|
|
14
14
|
Rate limiter uses a sliding window in memory:
|
|
15
15
|
TINA4_RATE_LIMIT=100 # Requests per window
|
|
16
16
|
TINA4_RATE_WINDOW=60 # Window in seconds
|
|
17
|
+
|
|
18
|
+
CSRF protection (off by default):
|
|
19
|
+
TINA4_CSRF=true # Enable CSRF token validation
|
|
17
20
|
"""
|
|
18
21
|
import os
|
|
19
22
|
import time
|
|
23
|
+
import logging
|
|
20
24
|
import threading
|
|
21
25
|
|
|
22
26
|
|
|
@@ -185,3 +189,122 @@ class SecurityHeadersMiddleware:
|
|
|
185
189
|
)
|
|
186
190
|
|
|
187
191
|
return request, response
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
class CsrfMiddleware:
|
|
195
|
+
"""CSRF token validation middleware.
|
|
196
|
+
|
|
197
|
+
Off by default — only active when TINA4_CSRF=true in .env or when
|
|
198
|
+
registered explicitly via Router.use(CsrfMiddleware).
|
|
199
|
+
|
|
200
|
+
Behaviour:
|
|
201
|
+
- Skips GET, HEAD, OPTIONS requests.
|
|
202
|
+
- Skips routes marked @noauth().
|
|
203
|
+
- Skips requests with a valid Authorization: Bearer header (API clients).
|
|
204
|
+
- Checks request.body["formToken"] then request.headers["X-Form-Token"].
|
|
205
|
+
- Rejects if token found in request.query["formToken"] (log warning, 403).
|
|
206
|
+
- Validates token with Auth.valid_token using SECRET env var.
|
|
207
|
+
- If token payload has session_id, verifies it matches request.session.session_id.
|
|
208
|
+
- Returns 403 with response.error("CSRF_INVALID", ...) on failure.
|
|
209
|
+
"""
|
|
210
|
+
|
|
211
|
+
_logger = logging.getLogger("tina4.csrf")
|
|
212
|
+
|
|
213
|
+
@staticmethod
|
|
214
|
+
def before_csrf(request, response):
|
|
215
|
+
"""Validate CSRF token before the route handler runs."""
|
|
216
|
+
# Check if CSRF is enabled via env (middleware registration bypasses this)
|
|
217
|
+
csrf_env = os.environ.get("TINA4_CSRF", "true").lower() not in ("false", "0", "no")
|
|
218
|
+
# When registered via Router.use(), this method always runs.
|
|
219
|
+
# The env check is only for auto-activation scenarios.
|
|
220
|
+
|
|
221
|
+
# Skip safe HTTP methods
|
|
222
|
+
method = getattr(request, "method", "GET").upper()
|
|
223
|
+
if method in ("GET", "HEAD", "OPTIONS"):
|
|
224
|
+
return request, response
|
|
225
|
+
|
|
226
|
+
# Skip routes marked @noauth()
|
|
227
|
+
handler = getattr(request, "_handler", None)
|
|
228
|
+
if handler and getattr(handler, "_noauth", False):
|
|
229
|
+
return request, response
|
|
230
|
+
|
|
231
|
+
# Skip requests with valid Bearer token (API clients)
|
|
232
|
+
auth_header = ""
|
|
233
|
+
headers = getattr(request, "headers", {})
|
|
234
|
+
if isinstance(headers, dict):
|
|
235
|
+
auth_header = headers.get("authorization", headers.get("Authorization", ""))
|
|
236
|
+
elif hasattr(headers, "get"):
|
|
237
|
+
auth_header = headers.get("authorization", "")
|
|
238
|
+
|
|
239
|
+
if auth_header.startswith("Bearer "):
|
|
240
|
+
bearer_token = auth_header[7:].strip()
|
|
241
|
+
if bearer_token:
|
|
242
|
+
from tina4_python.auth import Auth as _CsrfAuth
|
|
243
|
+
secret = os.environ.get("SECRET", "tina4-default-secret")
|
|
244
|
+
auth = _CsrfAuth(secret=secret)
|
|
245
|
+
if auth.valid_token(bearer_token) is not None:
|
|
246
|
+
return request, response
|
|
247
|
+
|
|
248
|
+
# Reject if token is in query string (security risk — log warning)
|
|
249
|
+
query = getattr(request, "params", None) or getattr(request, "query", None) or {}
|
|
250
|
+
if isinstance(query, dict) and query.get("formToken"):
|
|
251
|
+
CsrfMiddleware._logger.warning(
|
|
252
|
+
"CSRF token found in query string — rejected for security. "
|
|
253
|
+
"Use POST body or X-Form-Token header instead."
|
|
254
|
+
)
|
|
255
|
+
return request, response.error(
|
|
256
|
+
"CSRF_INVALID",
|
|
257
|
+
"Form token must not be sent in the URL query string",
|
|
258
|
+
403,
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
# Extract token: body first, then header
|
|
262
|
+
token = None
|
|
263
|
+
body = getattr(request, "body", None) or {}
|
|
264
|
+
if isinstance(body, dict):
|
|
265
|
+
token = body.get("formToken")
|
|
266
|
+
|
|
267
|
+
if not token:
|
|
268
|
+
if isinstance(headers, dict):
|
|
269
|
+
token = headers.get("x-form-token", headers.get("X-Form-Token", ""))
|
|
270
|
+
elif hasattr(headers, "get"):
|
|
271
|
+
token = headers.get("x-form-token", "")
|
|
272
|
+
|
|
273
|
+
if not token:
|
|
274
|
+
return request, response.error(
|
|
275
|
+
"CSRF_INVALID",
|
|
276
|
+
"Invalid or missing form token",
|
|
277
|
+
403,
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
# Validate the token
|
|
281
|
+
from tina4_python.auth import Auth as _CsrfAuth
|
|
282
|
+
secret = os.environ.get("SECRET", "tina4-default-secret")
|
|
283
|
+
auth = _CsrfAuth(secret=secret)
|
|
284
|
+
payload = auth.valid_token(token)
|
|
285
|
+
|
|
286
|
+
if payload is None:
|
|
287
|
+
return request, response.error(
|
|
288
|
+
"CSRF_INVALID",
|
|
289
|
+
"Invalid or missing form token",
|
|
290
|
+
403,
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
# Session binding — if token has session_id, verify it matches
|
|
294
|
+
token_session_id = payload.get("session_id")
|
|
295
|
+
if token_session_id:
|
|
296
|
+
session = getattr(request, "session", None)
|
|
297
|
+
current_session_id = None
|
|
298
|
+
if session is not None:
|
|
299
|
+
current_session_id = getattr(session, "session_id", None)
|
|
300
|
+
if current_session_id is None and hasattr(session, "get"):
|
|
301
|
+
current_session_id = session.get("session_id")
|
|
302
|
+
|
|
303
|
+
if current_session_id and token_session_id != current_session_id:
|
|
304
|
+
return request, response.error(
|
|
305
|
+
"CSRF_INVALID",
|
|
306
|
+
"Invalid or missing form token",
|
|
307
|
+
403,
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
return request, response
|
|
@@ -911,7 +911,8 @@ async def app(scope: dict, receive, send):
|
|
|
911
911
|
sid = request.session.session_id if hasattr(request.session, 'session_id') else getattr(request.session, 'id', None)
|
|
912
912
|
if sid:
|
|
913
913
|
ttl = int(os.environ.get("TINA4_SESSION_TTL", "3600"))
|
|
914
|
-
|
|
914
|
+
samesite = os.environ.get("TINA4_SESSION_SAMESITE", "Lax")
|
|
915
|
+
response.header("set-cookie", f"tina4_session={sid}; Path=/; HttpOnly; SameSite={samesite}; Max-Age={ttl}")
|
|
915
916
|
except Exception:
|
|
916
917
|
pass
|
|
917
918
|
|
|
@@ -1102,7 +1103,7 @@ def _print_banner(host: str, port: int, server_name: str = "asyncio"):
|
|
|
1102
1103
|
from tina4_python.dotenv import is_truthy
|
|
1103
1104
|
|
|
1104
1105
|
is_debug = is_truthy(os.environ.get("TINA4_DEBUG", ""))
|
|
1105
|
-
log_level = os.environ.get("TINA4_LOG_LEVEL", "
|
|
1106
|
+
log_level = os.environ.get("TINA4_LOG_LEVEL", "error").upper()
|
|
1106
1107
|
display = "localhost" if host in ("0.0.0.0", "::") else host
|
|
1107
1108
|
|
|
1108
1109
|
# Blue color for Python, only when stdout is a TTY
|
|
@@ -1145,7 +1146,7 @@ def run(host: str | None = None, port: int | None = None):
|
|
|
1145
1146
|
|
|
1146
1147
|
# Init logger
|
|
1147
1148
|
is_production = os.environ.get("TINA4_ENV", "development") == "production"
|
|
1148
|
-
log_level = os.environ.get("TINA4_LOG_LEVEL", "
|
|
1149
|
+
log_level = os.environ.get("TINA4_LOG_LEVEL", "error" if not is_production else "error")
|
|
1149
1150
|
Log.init(level=log_level, production=is_production)
|
|
1150
1151
|
|
|
1151
1152
|
# Ensure folders
|
|
@@ -578,7 +578,7 @@ def _wordwrap(text: str, width: int) -> str:
|
|
|
578
578
|
# ── Form Token ─────────────────────────────────────────────────
|
|
579
579
|
|
|
580
580
|
|
|
581
|
-
def _form_token(descriptor: str = "") -> str:
|
|
581
|
+
def _form_token(descriptor: str = "", session_id: str = "") -> str:
|
|
582
582
|
"""Generate a JWT form token and return a hidden input element.
|
|
583
583
|
|
|
584
584
|
Args:
|
|
@@ -586,6 +586,9 @@ def _form_token(descriptor: str = "") -> str:
|
|
|
586
586
|
- Empty or omitted: payload is ``{"type": "form"}``
|
|
587
587
|
- ``"admin_panel"``: payload is ``{"type": "form", "context": "admin_panel"}``
|
|
588
588
|
- ``"checkout|order_123"``: payload is ``{"type": "form", "context": "checkout", "ref": "order_123"}``
|
|
589
|
+
session_id: Optional session ID to bind the token to a specific session.
|
|
590
|
+
When provided, the CSRF middleware will verify the token belongs to
|
|
591
|
+
the same session. If empty, checks ``_form_token_session_id`` global.
|
|
589
592
|
|
|
590
593
|
Returns:
|
|
591
594
|
``<input type="hidden" name="formToken" value="TOKEN">``
|
|
@@ -600,6 +603,11 @@ def _form_token(descriptor: str = "") -> str:
|
|
|
600
603
|
else:
|
|
601
604
|
payload["context"] = descriptor
|
|
602
605
|
|
|
606
|
+
# Include session_id in payload for CSRF session binding
|
|
607
|
+
sid = session_id or _form_token_session_id
|
|
608
|
+
if sid:
|
|
609
|
+
payload["session_id"] = sid
|
|
610
|
+
|
|
603
611
|
secret = os.environ.get("SECRET", "tina4-default-secret")
|
|
604
612
|
ttl = int(os.environ.get("TINA4_TOKEN_EXPIRES_IN", "60"))
|
|
605
613
|
auth = _FrondAuth(secret=secret, expires_in=ttl)
|
|
@@ -607,6 +615,17 @@ def _form_token(descriptor: str = "") -> str:
|
|
|
607
615
|
return SafeString(f'<input type="hidden" name="formToken" value="{token}">')
|
|
608
616
|
|
|
609
617
|
|
|
618
|
+
# Module-level session ID holder — set by the server before rendering templates
|
|
619
|
+
# so that form_token() can bind tokens to the current session.
|
|
620
|
+
_form_token_session_id: str = ""
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
def set_form_token_session_id(session_id: str) -> None:
|
|
624
|
+
"""Set the session ID used by form_token() for CSRF session binding."""
|
|
625
|
+
global _form_token_session_id
|
|
626
|
+
_form_token_session_id = session_id or ""
|
|
627
|
+
|
|
628
|
+
|
|
610
629
|
# ── Frond Engine ────────────────────────────────────────────────
|
|
611
630
|
|
|
612
631
|
|
|
@@ -257,6 +257,152 @@ class QueryBuilder:
|
|
|
257
257
|
"""
|
|
258
258
|
return self.count() > 0
|
|
259
259
|
|
|
260
|
+
def to_mongo(self) -> dict:
|
|
261
|
+
"""Convert the fluent builder state into a MongoDB-compatible query.
|
|
262
|
+
|
|
263
|
+
Returns:
|
|
264
|
+
A dict with keys: filter, projection, sort, limit, skip.
|
|
265
|
+
Only non-empty keys are included.
|
|
266
|
+
"""
|
|
267
|
+
result = {}
|
|
268
|
+
|
|
269
|
+
# -- projection --
|
|
270
|
+
if self._columns != ["*"]:
|
|
271
|
+
result["projection"] = {col.strip(): 1 for col in self._columns}
|
|
272
|
+
|
|
273
|
+
# -- filter --
|
|
274
|
+
if self._wheres:
|
|
275
|
+
param_index = 0
|
|
276
|
+
and_conditions = []
|
|
277
|
+
or_conditions = []
|
|
278
|
+
|
|
279
|
+
for i, (connector, condition) in enumerate(self._wheres):
|
|
280
|
+
mongo_cond, param_index = self._parse_condition_to_mongo(
|
|
281
|
+
condition, param_index
|
|
282
|
+
)
|
|
283
|
+
if i == 0 or connector == "AND":
|
|
284
|
+
and_conditions.append(mongo_cond)
|
|
285
|
+
else:
|
|
286
|
+
or_conditions.append(mongo_cond)
|
|
287
|
+
|
|
288
|
+
if or_conditions:
|
|
289
|
+
# Merge AND conditions into a single dict, then $or with OR ones
|
|
290
|
+
and_merged = self._merge_mongo_conditions(and_conditions)
|
|
291
|
+
all_branches = [and_merged] + or_conditions
|
|
292
|
+
result["filter"] = {"$or": all_branches}
|
|
293
|
+
else:
|
|
294
|
+
result["filter"] = self._merge_mongo_conditions(and_conditions)
|
|
295
|
+
|
|
296
|
+
# -- sort --
|
|
297
|
+
if self._order_by_cols:
|
|
298
|
+
sort_list = []
|
|
299
|
+
for expr in self._order_by_cols:
|
|
300
|
+
parts = expr.strip().split()
|
|
301
|
+
field = parts[0]
|
|
302
|
+
direction = -1 if len(parts) > 1 and parts[1].upper() == "DESC" else 1
|
|
303
|
+
sort_list.append((field, direction))
|
|
304
|
+
result["sort"] = sort_list
|
|
305
|
+
|
|
306
|
+
# -- limit / skip --
|
|
307
|
+
if self._limit_val is not None:
|
|
308
|
+
result["limit"] = self._limit_val
|
|
309
|
+
if self._offset_val is not None:
|
|
310
|
+
result["skip"] = self._offset_val
|
|
311
|
+
|
|
312
|
+
return result
|
|
313
|
+
|
|
314
|
+
def _parse_condition_to_mongo(self, condition: str, param_index: int) -> tuple[dict, int]:
|
|
315
|
+
"""Parse a single SQL condition string into a MongoDB filter dict.
|
|
316
|
+
|
|
317
|
+
Returns:
|
|
318
|
+
(mongo_filter_dict, updated_param_index)
|
|
319
|
+
"""
|
|
320
|
+
import re
|
|
321
|
+
|
|
322
|
+
cond = condition.strip()
|
|
323
|
+
|
|
324
|
+
# IS NOT NULL
|
|
325
|
+
match = re.match(r"^(\w+)\s+IS\s+NOT\s+NULL$", cond, re.IGNORECASE)
|
|
326
|
+
if match:
|
|
327
|
+
return {match.group(1): {"$exists": True, "$ne": None}}, param_index
|
|
328
|
+
|
|
329
|
+
# IS NULL
|
|
330
|
+
match = re.match(r"^(\w+)\s+IS\s+NULL$", cond, re.IGNORECASE)
|
|
331
|
+
if match:
|
|
332
|
+
return {match.group(1): {"$exists": False}}, param_index
|
|
333
|
+
|
|
334
|
+
# NOT IN
|
|
335
|
+
match = re.match(r"^(\w+)\s+NOT\s+IN\s*\(\s*\?\s*\)$", cond, re.IGNORECASE)
|
|
336
|
+
if match:
|
|
337
|
+
val = self._params[param_index] if param_index < len(self._params) else []
|
|
338
|
+
values = val if isinstance(val, list) else [val]
|
|
339
|
+
return {match.group(1): {"$nin": values}}, param_index + 1
|
|
340
|
+
|
|
341
|
+
# IN
|
|
342
|
+
match = re.match(r"^(\w+)\s+IN\s*\(\s*\?\s*\)$", cond, re.IGNORECASE)
|
|
343
|
+
if match:
|
|
344
|
+
val = self._params[param_index] if param_index < len(self._params) else []
|
|
345
|
+
values = val if isinstance(val, list) else [val]
|
|
346
|
+
return {match.group(1): {"$in": values}}, param_index + 1
|
|
347
|
+
|
|
348
|
+
# LIKE
|
|
349
|
+
match = re.match(r"^(\w+)\s+LIKE\s+\?$", cond, re.IGNORECASE)
|
|
350
|
+
if match:
|
|
351
|
+
val = self._params[param_index] if param_index < len(self._params) else ""
|
|
352
|
+
# Convert SQL LIKE pattern to regex: % -> .*, _ -> .
|
|
353
|
+
pattern = str(val).replace("%", ".*").replace("_", ".")
|
|
354
|
+
return {match.group(1): {"$regex": pattern, "$options": "i"}}, param_index + 1
|
|
355
|
+
|
|
356
|
+
# Comparison operators: >=, <=, <>, !=, >, <, =
|
|
357
|
+
match = re.match(r"^(\w+)\s*(>=|<=|<>|!=|>|<|=)\s*\?$", cond)
|
|
358
|
+
if match:
|
|
359
|
+
field = match.group(1)
|
|
360
|
+
op = match.group(2)
|
|
361
|
+
val = self._params[param_index] if param_index < len(self._params) else None
|
|
362
|
+
op_map = {
|
|
363
|
+
"=": None,
|
|
364
|
+
"!=": "$ne",
|
|
365
|
+
"<>": "$ne",
|
|
366
|
+
">": "$gt",
|
|
367
|
+
">=": "$gte",
|
|
368
|
+
"<": "$lt",
|
|
369
|
+
"<=": "$lte",
|
|
370
|
+
}
|
|
371
|
+
mongo_op = op_map.get(op)
|
|
372
|
+
if mongo_op is None:
|
|
373
|
+
return {field: val}, param_index + 1
|
|
374
|
+
return {field: {mongo_op: val}}, param_index + 1
|
|
375
|
+
|
|
376
|
+
# Fallback: return condition as-is in $where (raw JS expression)
|
|
377
|
+
return {"$where": cond}, param_index
|
|
378
|
+
|
|
379
|
+
@staticmethod
|
|
380
|
+
def _merge_mongo_conditions(conditions: list[dict]) -> dict:
|
|
381
|
+
"""Merge a list of single-field mongo condition dicts into one dict.
|
|
382
|
+
|
|
383
|
+
If the same field appears in multiple conditions, wraps them in $and.
|
|
384
|
+
"""
|
|
385
|
+
if len(conditions) == 1:
|
|
386
|
+
return conditions[0]
|
|
387
|
+
|
|
388
|
+
merged = {}
|
|
389
|
+
conflicts = []
|
|
390
|
+
for cond in conditions:
|
|
391
|
+
for key, val in cond.items():
|
|
392
|
+
if key in merged:
|
|
393
|
+
conflicts.append(cond)
|
|
394
|
+
break
|
|
395
|
+
else:
|
|
396
|
+
merged[key] = val
|
|
397
|
+
else:
|
|
398
|
+
continue
|
|
399
|
+
# If we broke out due to conflict, don't add to merged
|
|
400
|
+
pass
|
|
401
|
+
|
|
402
|
+
if conflicts:
|
|
403
|
+
return {"$and": conditions}
|
|
404
|
+
return merged
|
|
405
|
+
|
|
260
406
|
# -- Private helpers --
|
|
261
407
|
|
|
262
408
|
def _build_where(self) -> str:
|