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.
Files changed (139) hide show
  1. {tina4_python-3.9.0 → tina4_python-3.9.2}/PKG-INFO +1 -1
  2. {tina4_python-3.9.0 → tina4_python-3.9.2}/pyproject.toml +1 -1
  3. {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/CLAUDE.md +7 -7
  4. {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/__init__.py +1 -1
  5. {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/auth/__init__.py +3 -3
  6. {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/cli/__init__.py +31 -0
  7. {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/core/middleware.py +126 -3
  8. {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/core/server.py +4 -3
  9. {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/frond/engine.py +20 -1
  10. {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/query_builder/__init__.py +146 -0
  11. {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/queue/__init__.py +232 -132
  12. {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/session/__init__.py +6 -1
  13. tina4_python-3.9.2/tina4_python/websocket/backplane.py +121 -0
  14. {tina4_python-3.9.0 → tina4_python-3.9.2}/.gitignore +0 -0
  15. {tina4_python-3.9.0 → tina4_python-3.9.2}/README.md +0 -0
  16. {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/HtmlElement.py +0 -0
  17. {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/Testing.py +0 -0
  18. {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/ai/__init__.py +0 -0
  19. {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/api/__init__.py +0 -0
  20. {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/cache/__init__.py +0 -0
  21. {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/container/__init__.py +0 -0
  22. {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/core/__init__.py +0 -0
  23. {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/core/cache.py +0 -0
  24. {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/core/constants.py +0 -0
  25. {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/core/events.py +0 -0
  26. {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/core/request.py +0 -0
  27. {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/core/response.py +0 -0
  28. {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/core/router.py +0 -0
  29. {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/crud/__init__.py +0 -0
  30. {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/database/__init__.py +0 -0
  31. {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/database/adapter.py +0 -0
  32. {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/database/connection.py +0 -0
  33. {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/database/firebird.py +0 -0
  34. {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/database/mssql.py +0 -0
  35. {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/database/mysql.py +0 -0
  36. {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/database/odbc.py +0 -0
  37. {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/database/postgres.py +0 -0
  38. {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/database/sqlite.py +0 -0
  39. {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/debug/__init__.py +0 -0
  40. {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/debug/error_overlay.py +0 -0
  41. {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/dev_admin/__init__.py +0 -0
  42. {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/dev_reload.py +0 -0
  43. {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/dotenv/__init__.py +0 -0
  44. {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/frond/FROND.md +0 -0
  45. {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/frond/__init__.py +0 -0
  46. {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/gallery/auth/meta.json +0 -0
  47. {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/gallery/auth/src/routes/api/gallery_auth.py +0 -0
  48. {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/gallery/database/meta.json +0 -0
  49. {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/gallery/database/src/routes/api/gallery_db.py +0 -0
  50. {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/gallery/error-overlay/meta.json +0 -0
  51. {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/gallery/error-overlay/src/routes/api/gallery_crash.py +0 -0
  52. {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/gallery/orm/meta.json +0 -0
  53. {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/gallery/orm/src/orm/Product.py +0 -0
  54. {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/gallery/orm/src/routes/api/gallery_products.py +0 -0
  55. {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/gallery/queue/meta.json +0 -0
  56. {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/gallery/queue/src/routes/api/gallery_queue.py +0 -0
  57. {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/gallery/rest-api/meta.json +0 -0
  58. {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/gallery/rest-api/src/routes/api/gallery_hello.py +0 -0
  59. {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/gallery/templates/meta.json +0 -0
  60. {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/gallery/templates/src/routes/gallery_page.py +0 -0
  61. {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/gallery/templates/src/templates/gallery_page.twig +0 -0
  62. {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/graphql/__init__.py +0 -0
  63. {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/i18n/__init__.py +0 -0
  64. {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/messenger/__init__.py +0 -0
  65. {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/migration/__init__.py +0 -0
  66. {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/migration/runner.py +0 -0
  67. {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/orm/__init__.py +0 -0
  68. {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/orm/fields.py +0 -0
  69. {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/orm/model.py +0 -0
  70. {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/public/css/tina4.css +0 -0
  71. {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/public/css/tina4.min.css +0 -0
  72. {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/public/favicon.ico +0 -0
  73. {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/public/images/logo.svg +0 -0
  74. {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/public/images/tina4-logo-icon.webp +0 -0
  75. {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/public/js/frond.min.js +0 -0
  76. {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/public/js/tina4-dev-admin.min.js +0 -0
  77. {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/public/js/tina4.min.js +0 -0
  78. {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/public/js/tina4js.min.js +0 -0
  79. {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/public/swagger/index.html +0 -0
  80. {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/public/swagger/oauth2-redirect.html +0 -0
  81. {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/queue_backends/__init__.py +0 -0
  82. {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/queue_backends/kafka_backend.py +0 -0
  83. {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/queue_backends/mongo_backend.py +0 -0
  84. {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/queue_backends/rabbitmq_backend.py +0 -0
  85. {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/scss/__init__.py +0 -0
  86. {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/scss/tina4css/_alerts.scss +0 -0
  87. {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/scss/tina4css/_badges.scss +0 -0
  88. {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/scss/tina4css/_buttons.scss +0 -0
  89. {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/scss/tina4css/_cards.scss +0 -0
  90. {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/scss/tina4css/_forms.scss +0 -0
  91. {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/scss/tina4css/_grid.scss +0 -0
  92. {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/scss/tina4css/_modals.scss +0 -0
  93. {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/scss/tina4css/_nav.scss +0 -0
  94. {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/scss/tina4css/_reset.scss +0 -0
  95. {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/scss/tina4css/_tables.scss +0 -0
  96. {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/scss/tina4css/_typography.scss +0 -0
  97. {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/scss/tina4css/_utilities.scss +0 -0
  98. {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/scss/tina4css/_variables.scss +0 -0
  99. {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/scss/tina4css/base.scss +0 -0
  100. {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/scss/tina4css/colors.scss +0 -0
  101. {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/scss/tina4css/tina4.scss +0 -0
  102. {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/seeder/__init__.py +0 -0
  103. {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/service/__init__.py +0 -0
  104. {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/session_handlers/__init__.py +0 -0
  105. {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/session_handlers/mongodb_handler.py +0 -0
  106. {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/session_handlers/redis_handler.py +0 -0
  107. {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/session_handlers/valkey_handler.py +0 -0
  108. {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/swagger/__init__.py +0 -0
  109. {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/templates/components/crud.twig +0 -0
  110. {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/templates/docker/distroless/Dockerfile +0 -0
  111. {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/templates/docker/poetry/Dockerfile +0 -0
  112. {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/templates/docker/python/Dockerfile +0 -0
  113. {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/templates/docker/uv/Dockerfile +0 -0
  114. {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/templates/errors/302.twig +0 -0
  115. {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/templates/errors/401.twig +0 -0
  116. {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/templates/errors/403.twig +0 -0
  117. {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/templates/errors/404.twig +0 -0
  118. {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/templates/errors/500.twig +0 -0
  119. {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/templates/errors/502.twig +0 -0
  120. {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/templates/errors/503.twig +0 -0
  121. {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/templates/errors/base.twig +0 -0
  122. {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/templates/frontend/README.md +0 -0
  123. {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/templates/readme.md +0 -0
  124. {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/test_client/__init__.py +0 -0
  125. {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/translations/af/LC_MESSAGES/messages.mo +0 -0
  126. {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/translations/af/LC_MESSAGES/messages.po +0 -0
  127. {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/translations/en/LC_MESSAGES/messages.mo +0 -0
  128. {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/translations/en/LC_MESSAGES/messages.po +0 -0
  129. {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/translations/es/LC_MESSAGES/messages.mo +0 -0
  130. {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/translations/es/LC_MESSAGES/messages.po +0 -0
  131. {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/translations/fr/LC_MESSAGES/messages.mo +0 -0
  132. {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/translations/fr/LC_MESSAGES/messages.po +0 -0
  133. {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/translations/ja/LC_MESSAGES/messages.mo +0 -0
  134. {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/translations/ja/LC_MESSAGES/messages.po +0 -0
  135. {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/translations/zh/LC_MESSAGES/messages.mo +0 -0
  136. {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/translations/zh/LC_MESSAGES/messages.po +0 -0
  137. {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/validator/__init__.py +0 -0
  138. {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/websocket/__init__.py +0 -0
  139. {tina4_python-3.9.0 → tina4_python-3.9.2}/tina4_python/wsdl/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tina4-python
3
- Version: 3.9.0
3
+ Version: 3.9.2
4
4
  Summary: Tina4 Python v3 — Zero-dependency, lightweight web framework
5
5
  Author-email: Andre van Zuydam <andrevanzuydam@gmail.com>
6
6
  License: MIT
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "tina4-python"
3
- version = "3.9.0"
3
+ version = "3.9.2"
4
4
  description = "Tina4 Python v3 — Zero-dependency, lightweight web framework"
5
5
  authors = [
6
6
  {name = "Andre van Zuydam", email = "andrevanzuydam@gmail.com"}
@@ -480,7 +480,7 @@ Response.add_header("X-Custom", "value")
480
480
 
481
481
  ## Sessions
482
482
 
483
- TINA4_TOKEN_EXPIRES_IN is used to set the session time, recommend 15-60 minutes
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: litequeue (default/SQLite, zero-config), RabbitMQ, Kafka, MongoDB.
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
- API_KEY=your-api-key # Static bearer token for API auth
1519
- TINA4_TOKEN_EXPIRES_IN=60 # Token lifetime in minutes (default: 60)
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=ALL # Log verbosity: ALL, DEBUG, INFO, WARNING, ERROR
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=My API # API title (default: "Tina4 Python API")
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**: SQLite/RabbitMQ/Kafka/MongoDB backends, configured via env vars
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=`)
@@ -8,7 +8,7 @@ Tina4 Python v3.0 — Zero-dependency, lightweight web framework.
8
8
 
9
9
  One import, everything works.
10
10
  """
11
- __version__ = "3.8.3"
11
+ __version__ = "3.9.2"
12
12
 
13
13
  # ── HTTP Constants ──
14
14
  from tina4_python.core.constants import ( # noqa: E402, F401
@@ -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("TINA4_TOKEN_EXPIRES_IN", "60")
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 API_KEY env var."""
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 and Rate Limiter.
1
+ # Tina4 Middleware — CORS, Rate Limiter, CSRF.
2
2
  """
3
- Built-in middleware for cross-origin requests and rate limiting.
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
- response.header("set-cookie", f"tina4_session={sid}; Path=/; HttpOnly; SameSite=Lax; Max-Age={ttl}")
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", "debug").upper()
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", "debug" if not is_production else "info")
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: