tina4-python 3.13.106__tar.gz → 3.13.108__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.13.106 → tina4_python-3.13.108}/PKG-INFO +3 -4
- {tina4_python-3.13.106 → tina4_python-3.13.108}/README.md +2 -3
- {tina4_python-3.13.106 → tina4_python-3.13.108}/pyproject.toml +1 -1
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/__init__.py +2 -2
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/cache/__init__.py +35 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/core/router.py +144 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/core/server.py +16 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/database/adapter.py +58 -13
- tina4_python-3.13.108/tina4_python/public/js/tina4js.min.js +48 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/service/__init__.py +10 -0
- tina4_python-3.13.106/tina4_python/public/js/tina4js.min.js +0 -48
- {tina4_python-3.13.106 → tina4_python-3.13.108}/.gitignore +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/CLAUDE.md +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/HtmlElement.py +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/Testing.py +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/ai/__init__.py +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/ai/client.py +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/api/__init__.py +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/auth/__init__.py +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/cli/__init__.py +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/container/__init__.py +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/context/__init__.py +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/context/chunker.py +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/core/__init__.py +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/core/cache.py +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/core/constants.py +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/core/events.py +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/core/middleware.py +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/core/port_takeover.py +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/core/rate_limiter.py +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/core/request.py +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/core/response.py +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/crud/__init__.py +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/database/__init__.py +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/database/connection.py +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/database/database_url.py +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/database/firebird.py +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/database/mongodb.py +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/database/mssql.py +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/database/mysql.py +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/database/odbc.py +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/database/postgres.py +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/database/sql_translator.py +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/database/sqlite.py +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/debug/__init__.py +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/debug/error_overlay.py +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/dev_admin/__init__.py +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/dev_admin/metrics.py +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/dev_admin/plan.py +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/dev_admin/project_index.py +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/docs.py +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/docstore/__init__.py +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/dotenv/__init__.py +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/env.py +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/frond/FROND.md +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/frond/__init__.py +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/frond/compiler.py +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/frond/engine.py +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/frond/parser.py +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/gallery/auth/meta.json +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/gallery/auth/src/routes/api/gallery_auth.py +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/gallery/database/meta.json +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/gallery/database/src/routes/api/gallery_db.py +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/gallery/error-overlay/meta.json +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/gallery/error-overlay/src/routes/api/gallery_crash.py +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/gallery/orm/meta.json +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/gallery/orm/src/orm/Product.py +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/gallery/orm/src/routes/api/gallery_products.py +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/gallery/queue/meta.json +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/gallery/queue/src/routes/api/gallery_queue.py +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/gallery/rest-api/meta.json +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/gallery/rest-api/src/routes/api/gallery_hello.py +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/gallery/templates/meta.json +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/gallery/templates/src/routes/gallery_page.py +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/gallery/templates/src/templates/gallery_page.twig +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/graphql/__init__.py +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/i18n/__init__.py +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/mcp/__init__.py +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/mcp/protocol.py +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/mcp/tools.py +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/messenger/__init__.py +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/migration/__init__.py +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/migration/runner.py +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/mqtt/__init__.py +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/mqtt/message.py +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/orm/__init__.py +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/orm/fields.py +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/orm/model.py +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/orm/point.py +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/public/__feedback/widget.js +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/public/css/tina4.css +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/public/css/tina4.min.css +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/public/favicon.ico +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/public/images/logo.svg +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/public/images/tina4-logo-icon.webp +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/public/js/frond.js +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/public/js/frond.min.js +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/public/js/tina4-dev-admin.min.js +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/public/js/tina4.min.js +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/public/swagger/index.html +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/public/swagger/oauth2-redirect.html +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/query_builder/__init__.py +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/queue/__init__.py +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/queue/amqp_url.py +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/queue/job.py +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/queue/kafka_backend.py +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/queue/lite_backend.py +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/queue/mongo_backend.py +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/queue/rabbitmq_backend.py +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/queue_backends/__init__.py +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/queue_backends/kafka_backend.py +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/queue_backends/mongo_backend.py +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/queue_backends/rabbitmq_backend.py +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/realtime/__init__.py +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/realtime/models/Attachment.py +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/realtime/models/Channel.py +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/realtime/models/ChannelMember.py +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/realtime/models/Message.py +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/realtime/models/Workspace.py +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/realtime/models/__init__.py +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/realtime/storage.py +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/seeder/__init__.py +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/session/__init__.py +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/session_handlers/__init__.py +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/session_handlers/memcached_handler.py +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/session_handlers/mongodb_handler.py +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/session_handlers/redis_handler.py +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/session_handlers/valkey_handler.py +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/sso.py +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/swagger/__init__.py +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/templates/components/crud.twig +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/templates/docker/distroless/Dockerfile +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/templates/docker/poetry/Dockerfile +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/templates/docker/python/Dockerfile +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/templates/docker/uv/Dockerfile +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/templates/errors/302.twig +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/templates/errors/401.twig +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/templates/errors/403.twig +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/templates/errors/404.twig +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/templates/errors/500.twig +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/templates/errors/502.twig +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/templates/errors/503.twig +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/templates/errors/base.twig +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/templates/frontend/README.md +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/templates/readme.md +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/test/__init__.py +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/test_client/__init__.py +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/translations/af/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/translations/af/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/translations/en/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/translations/en/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/translations/es/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/translations/es/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/translations/fr/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/translations/fr/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/translations/ja/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/translations/ja/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/translations/zh/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/translations/zh/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/validator/__init__.py +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/websocket/__init__.py +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/websocket/backplane.py +0 -0
- {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/wsdl/__init__.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: tina4-python
|
|
3
|
-
Version: 3.13.
|
|
3
|
+
Version: 3.13.108
|
|
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
|
|
@@ -56,13 +56,12 @@ Description-Content-Type: text/markdown
|
|
|
56
56
|
<h1 align="center">Tina4 Python</h1>
|
|
57
57
|
|
|
58
58
|
<p align="center">
|
|
59
|
-
|
|
59
|
+
Zero dependencies. One import, everything works.
|
|
60
60
|
</p>
|
|
61
61
|
|
|
62
62
|
<p align="center">
|
|
63
63
|
<a href="https://pypi.org/project/tina4-python/"><img src="https://img.shields.io/pypi/v/tina4-python?color=7b1fa2&label=PyPI" alt="PyPI"></a>
|
|
64
|
-
<img src="https://img.shields.io/
|
|
65
|
-
<img src="https://img.shields.io/badge/features-98-blue" alt="Features">
|
|
64
|
+
<a href="https://github.com/tina4stack/tina4-python/actions/workflows/test.yml"><img src="https://img.shields.io/github/actions/workflow/status/tina4stack/tina4-python/test.yml?label=tests" alt="Tests"></a>
|
|
66
65
|
<img src="https://img.shields.io/badge/dependencies-0-brightgreen" alt="Zero Deps">
|
|
67
66
|
<a href="https://tina4.com"><img src="https://img.shields.io/badge/docs-tina4.com-7b1fa2" alt="Docs"></a>
|
|
68
67
|
</p>
|
|
@@ -5,13 +5,12 @@
|
|
|
5
5
|
<h1 align="center">Tina4 Python</h1>
|
|
6
6
|
|
|
7
7
|
<p align="center">
|
|
8
|
-
|
|
8
|
+
Zero dependencies. One import, everything works.
|
|
9
9
|
</p>
|
|
10
10
|
|
|
11
11
|
<p align="center">
|
|
12
12
|
<a href="https://pypi.org/project/tina4-python/"><img src="https://img.shields.io/pypi/v/tina4-python?color=7b1fa2&label=PyPI" alt="PyPI"></a>
|
|
13
|
-
<img src="https://img.shields.io/
|
|
14
|
-
<img src="https://img.shields.io/badge/features-98-blue" alt="Features">
|
|
13
|
+
<a href="https://github.com/tina4stack/tina4-python/actions/workflows/test.yml"><img src="https://img.shields.io/github/actions/workflow/status/tina4stack/tina4-python/test.yml?label=tests" alt="Tests"></a>
|
|
15
14
|
<img src="https://img.shields.io/badge/dependencies-0-brightgreen" alt="Zero Deps">
|
|
16
15
|
<a href="https://tina4.com"><img src="https://img.shields.io/badge/docs-tina4.com-7b1fa2" alt="Docs"></a>
|
|
17
16
|
</p>
|
|
@@ -51,7 +51,7 @@ def _resolve_version() -> str:
|
|
|
51
51
|
#
|
|
52
52
|
# test_version_constant.py now asserts this literal equals the pyproject
|
|
53
53
|
# version, so the release bump cannot leave it behind again.
|
|
54
|
-
return "3.13.
|
|
54
|
+
return "3.13.108"
|
|
55
55
|
|
|
56
56
|
|
|
57
57
|
__version__ = _resolve_version()
|
|
@@ -71,7 +71,7 @@ __version__ = _resolve_version()
|
|
|
71
71
|
# ── CORE (always eager) — every app needs these to boot + serve ──
|
|
72
72
|
from tina4_python.core.router import ( # noqa: E402, F401
|
|
73
73
|
get, post, put, patch, delete, any_method,
|
|
74
|
-
noauth, secured, cached, middleware, template,
|
|
74
|
+
noauth, secured, role, can, cached, middleware, template,
|
|
75
75
|
Router, RouteGroup,
|
|
76
76
|
)
|
|
77
77
|
# websocket is not in that tuple. The tina4_python.websocket *subpackage* is
|
|
@@ -1190,6 +1190,17 @@ def _vary_fields(response) -> list:
|
|
|
1190
1190
|
return [f.strip().lower() for f in raw.split(",") if f.strip()]
|
|
1191
1191
|
|
|
1192
1192
|
|
|
1193
|
+
def _cache_control_tokens(carrier) -> set:
|
|
1194
|
+
"""The Cache-Control directive names on a request or response, lowercased.
|
|
1195
|
+
|
|
1196
|
+
Parsed as comma-separated tokens with any ``=value`` stripped, rather than by
|
|
1197
|
+
substring search, so ``no-cache="Set-Cookie"`` is recognised as ``no-cache``
|
|
1198
|
+
and a directive name never matches as a fragment of a longer one.
|
|
1199
|
+
"""
|
|
1200
|
+
raw = _header_value(carrier, "Cache-Control") or ""
|
|
1201
|
+
return {token.split("=", 1)[0].strip().lower() for token in raw.split(",") if token.strip()}
|
|
1202
|
+
|
|
1203
|
+
|
|
1193
1204
|
def _shared_cache_allowed(response) -> bool:
|
|
1194
1205
|
"""Does the response carry a directive that lets a shared cache store it?"""
|
|
1195
1206
|
cc = (_header_value(response, "Cache-Control") or "").lower()
|
|
@@ -1373,11 +1384,35 @@ class ResponseCache:
|
|
|
1373
1384
|
URL, because the key is method + URL only.
|
|
1374
1385
|
* s4.1 — "A stored response with a Vary header field value containing a
|
|
1375
1386
|
member '*' always fails to match", so storing one is pointless.
|
|
1387
|
+
|
|
1388
|
+
The same s3 reasoning applies to two cases the Authorization check alone
|
|
1389
|
+
does not cover, and both were storable before 3.13.108:
|
|
1390
|
+
|
|
1391
|
+
* ``no-store`` forbids storage in any cache and ``private`` forbids it in
|
|
1392
|
+
a shared one. Neither was honoured, so a handler had no way at all to
|
|
1393
|
+
keep a response out of this cache — setting the correct standard header
|
|
1394
|
+
did nothing.
|
|
1395
|
+
* A caller identified by a session cookie is as specific as one carrying
|
|
1396
|
+
Authorization, and Tina4's own session mechanism IS a cookie. Because
|
|
1397
|
+
the key is method + URL, storing such a response replays one signed-in
|
|
1398
|
+
user's page to whoever asks for that URL next. A response that installs
|
|
1399
|
+
a session is per-user by construction for the same reason.
|
|
1400
|
+
|
|
1401
|
+
Both cookie cases stay cacheable when the response opts in explicitly with
|
|
1402
|
+
a shared-cache directive, which is how a genuinely public page served to
|
|
1403
|
+
cookie-bearing browsers keeps its hit rate.
|
|
1376
1404
|
"""
|
|
1377
1405
|
if "*" in _vary_fields(response):
|
|
1378
1406
|
return False
|
|
1407
|
+
directives = _cache_control_tokens(response)
|
|
1408
|
+
if directives & {"no-store", "private", "no-cache"}:
|
|
1409
|
+
return False
|
|
1379
1410
|
if _header_value(request, "Authorization") is not None:
|
|
1380
1411
|
return _shared_cache_allowed(response)
|
|
1412
|
+
if _header_value(request, "Cookie") is not None:
|
|
1413
|
+
return _shared_cache_allowed(response)
|
|
1414
|
+
if _header_value(response, "Set-Cookie") is not None:
|
|
1415
|
+
return _shared_cache_allowed(response)
|
|
1381
1416
|
return True
|
|
1382
1417
|
|
|
1383
1418
|
@staticmethod
|
|
@@ -50,6 +50,24 @@ class RouteRef:
|
|
|
50
50
|
self._route["auth_required"] = False
|
|
51
51
|
return self
|
|
52
52
|
|
|
53
|
+
def role(self, *names):
|
|
54
|
+
"""Require the caller to hold ONE of the named roles (OR). Reads the
|
|
55
|
+
verified JWT ``roles`` claim. Implies auth (a guarded GET still needs a
|
|
56
|
+
token). Stack ``.role()``/``.can()`` for AND. Feature 138 / ADR-0058."""
|
|
57
|
+
if names:
|
|
58
|
+
self._route.setdefault("required_roles", []).append(tuple(names))
|
|
59
|
+
self._route["auth_required"] = True
|
|
60
|
+
return self
|
|
61
|
+
|
|
62
|
+
def can(self, *permissions):
|
|
63
|
+
"""Require the caller to hold ONE of the named permissions (OR). Reads
|
|
64
|
+
the verified JWT ``permissions`` claim; granted-side wildcards (``posts.*``,
|
|
65
|
+
``*``) satisfy a concrete requirement. Implies auth. Feature 138."""
|
|
66
|
+
if permissions:
|
|
67
|
+
self._route.setdefault("required_perms", []).append(tuple(permissions))
|
|
68
|
+
self._route["auth_required"] = True
|
|
69
|
+
return self
|
|
70
|
+
|
|
53
71
|
def cache(self):
|
|
54
72
|
"""Mark this route as cacheable."""
|
|
55
73
|
self._route["cached"] = True
|
|
@@ -371,6 +389,12 @@ class Router:
|
|
|
371
389
|
auth_required = False
|
|
372
390
|
elif hasattr(handler, "_secured"):
|
|
373
391
|
auth_required = True
|
|
392
|
+
elif hasattr(handler, "_required_roles") or hasattr(handler, "_required_perms"):
|
|
393
|
+
# A role/permission guard (Feature 138) implies auth — a guarded GET
|
|
394
|
+
# still requires a token. This branch only fires in the unusual
|
|
395
|
+
# innermost-order case (guard BELOW @get); the documented order puts
|
|
396
|
+
# the guard ABOVE, where it flips auth_required via the RouteRef.
|
|
397
|
+
auth_required = True
|
|
374
398
|
else:
|
|
375
399
|
# GET, HEAD, OPTIONS, and ANY are public by default. HEAD and
|
|
376
400
|
# OPTIONS are safe/idempotent introspection methods (RFC 9110
|
|
@@ -392,6 +416,11 @@ class Router:
|
|
|
392
416
|
"module": getattr(handler, "__module__", ""),
|
|
393
417
|
"middleware": effective_middleware,
|
|
394
418
|
"auth_required": auth_required,
|
|
419
|
+
# RBAC guards (Feature 138): lists of OR-groups; AND across groups.
|
|
420
|
+
# Populated here for the innermost-order case, and appended to by the
|
|
421
|
+
# RouteRef.role()/.can() modifiers for the documented guard-above order.
|
|
422
|
+
"required_roles": list(getattr(handler, "_required_roles", []) or []),
|
|
423
|
+
"required_perms": list(getattr(handler, "_required_perms", []) or []),
|
|
395
424
|
"cached": options.get("cached", False),
|
|
396
425
|
"cache_max_age": options.get("cache_max_age", 60),
|
|
397
426
|
"swagger_meta": swagger_meta or options.get("swagger_meta", {}),
|
|
@@ -862,6 +891,121 @@ def secured():
|
|
|
862
891
|
return decorator
|
|
863
892
|
|
|
864
893
|
|
|
894
|
+
# ── RBAC guards (Feature 138 / ADR-0058) ──────────────────────
|
|
895
|
+
# Claim-first authorization on top of the JWT auth gate. @role reads the verified
|
|
896
|
+
# `roles` claim, @can reads `permissions`. Multiple args are OR; stack guards for
|
|
897
|
+
# AND. A guard implies @secured. Roles and permissions are independent claims;
|
|
898
|
+
# the core never expands a role into permissions.
|
|
899
|
+
|
|
900
|
+
def role(*names):
|
|
901
|
+
"""Require the caller to hold ONE of the named roles (OR). Stack
|
|
902
|
+
``@role``/``@can`` for AND. Reads the verified JWT ``roles`` claim (a legacy
|
|
903
|
+
singular ``role`` string is coerced). Implies ``@secured``."""
|
|
904
|
+
group = tuple(names)
|
|
905
|
+
|
|
906
|
+
def decorator(fn):
|
|
907
|
+
fn._required_roles = getattr(fn, "_required_roles", []) + [group]
|
|
908
|
+
if hasattr(fn, "_route_ref"):
|
|
909
|
+
route = fn._route_ref._route
|
|
910
|
+
route.setdefault("required_roles", []).append(group)
|
|
911
|
+
was_public = not route.get("auth_required", True)
|
|
912
|
+
route["auth_required"] = True
|
|
913
|
+
if was_public:
|
|
914
|
+
Log.debug(
|
|
915
|
+
f"Route auth updated: {route['method']} {route['path']} "
|
|
916
|
+
f"(auth=required via @role)"
|
|
917
|
+
)
|
|
918
|
+
return fn
|
|
919
|
+
return decorator
|
|
920
|
+
|
|
921
|
+
|
|
922
|
+
def can(*permissions):
|
|
923
|
+
"""Require the caller to hold ONE of the named permissions (OR). Stack
|
|
924
|
+
``@role``/``@can`` for AND. Reads the verified JWT ``permissions`` claim;
|
|
925
|
+
granted-side wildcards (``posts.*``, ``*``) satisfy a concrete requirement.
|
|
926
|
+
Implies ``@secured``."""
|
|
927
|
+
group = tuple(permissions)
|
|
928
|
+
|
|
929
|
+
def decorator(fn):
|
|
930
|
+
fn._required_perms = getattr(fn, "_required_perms", []) + [group]
|
|
931
|
+
if hasattr(fn, "_route_ref"):
|
|
932
|
+
route = fn._route_ref._route
|
|
933
|
+
route.setdefault("required_perms", []).append(group)
|
|
934
|
+
was_public = not route.get("auth_required", True)
|
|
935
|
+
route["auth_required"] = True
|
|
936
|
+
if was_public:
|
|
937
|
+
Log.debug(
|
|
938
|
+
f"Route auth updated: {route['method']} {route['path']} "
|
|
939
|
+
f"(auth=required via @can)"
|
|
940
|
+
)
|
|
941
|
+
return fn
|
|
942
|
+
return decorator
|
|
943
|
+
|
|
944
|
+
|
|
945
|
+
def _rbac_claim_list(subject, key, legacy=None):
|
|
946
|
+
"""Read a claim as a list of strings from the VERIFIED payload. Coerces a
|
|
947
|
+
legacy singular string (``role`` -> ``["role"]``). Returns ``[]`` for a
|
|
948
|
+
missing/None subject or claim."""
|
|
949
|
+
if not isinstance(subject, dict):
|
|
950
|
+
return []
|
|
951
|
+
val = subject.get(key)
|
|
952
|
+
out = []
|
|
953
|
+
if isinstance(val, str) and val:
|
|
954
|
+
out = [val]
|
|
955
|
+
elif isinstance(val, (list, tuple)):
|
|
956
|
+
out = [str(x) for x in val if x is not None and str(x) != ""]
|
|
957
|
+
if not out and legacy:
|
|
958
|
+
lv = subject.get(legacy)
|
|
959
|
+
if isinstance(lv, str) and lv:
|
|
960
|
+
out = [lv]
|
|
961
|
+
elif isinstance(lv, (list, tuple)):
|
|
962
|
+
out = [str(x) for x in lv if x is not None and str(x) != ""]
|
|
963
|
+
return out
|
|
964
|
+
|
|
965
|
+
|
|
966
|
+
def _rbac_perm_granted(granted, required):
|
|
967
|
+
"""True if any GRANTED permission satisfies the concrete REQUIRED permission.
|
|
968
|
+
Wildcards live only on the granted side: ``*`` grants everything; ``posts.*``
|
|
969
|
+
grants ``posts.<anything...>`` on the dot boundary (never ``users.delete``)."""
|
|
970
|
+
for g in granted:
|
|
971
|
+
if g == "*" or g == required:
|
|
972
|
+
return True
|
|
973
|
+
if g.endswith(".*") and required.startswith(g[:-1]):
|
|
974
|
+
return True
|
|
975
|
+
return False
|
|
976
|
+
|
|
977
|
+
|
|
978
|
+
def rbac_authorized(subject, required_roles, required_perms):
|
|
979
|
+
"""Return True if the verified ``subject`` satisfies every guard group.
|
|
980
|
+
|
|
981
|
+
AND across groups, OR within a group. ``required_roles`` / ``required_perms``
|
|
982
|
+
are lists of OR-groups (each ``@role``/``@can`` adds one group). Roles read
|
|
983
|
+
the ``roles`` claim (legacy singular ``role`` coerced); permissions read
|
|
984
|
+
``permissions`` with granted-side wildcards. A missing subject satisfies no
|
|
985
|
+
group. Feature 138 / ADR-0058."""
|
|
986
|
+
# NOTE: explicit loops, NOT any(...): this module shadows the builtin `any`
|
|
987
|
+
# (a route helper), so `any(...)` here would resolve to the wrong callable.
|
|
988
|
+
roles = _rbac_claim_list(subject, "roles", legacy="role")
|
|
989
|
+
for group in (required_roles or []):
|
|
990
|
+
matched = False
|
|
991
|
+
for r in group:
|
|
992
|
+
if r in roles:
|
|
993
|
+
matched = True
|
|
994
|
+
break
|
|
995
|
+
if not matched:
|
|
996
|
+
return False
|
|
997
|
+
perms = _rbac_claim_list(subject, "permissions")
|
|
998
|
+
for group in (required_perms or []):
|
|
999
|
+
matched = False
|
|
1000
|
+
for req in group:
|
|
1001
|
+
if _rbac_perm_granted(perms, req):
|
|
1002
|
+
matched = True
|
|
1003
|
+
break
|
|
1004
|
+
if not matched:
|
|
1005
|
+
return False
|
|
1006
|
+
return True
|
|
1007
|
+
|
|
1008
|
+
|
|
865
1009
|
# ── Middleware Decorator ───────────────────────────────────────
|
|
866
1010
|
|
|
867
1011
|
def middleware(*middleware_classes):
|
|
@@ -1804,6 +1804,22 @@ def _check_auth(request: Request, response: Response, route: dict) -> bool:
|
|
|
1804
1804
|
"status": 401,
|
|
1805
1805
|
})
|
|
1806
1806
|
return True
|
|
1807
|
+
# ── RBAC guards (Feature 138): authorization AFTER authentication ──
|
|
1808
|
+
# Auth passed (401 already ruled out above). If the route carries role/
|
|
1809
|
+
# permission guards, the verified payload (request.user) must satisfy them,
|
|
1810
|
+
# else 403 — authenticated but forbidden. An API-key auth leaves request.user
|
|
1811
|
+
# unset, so it carries no roles and is correctly forbidden from a guarded route.
|
|
1812
|
+
_req_roles = route.get("required_roles") or []
|
|
1813
|
+
_req_perms = route.get("required_perms") or []
|
|
1814
|
+
if _req_roles or _req_perms:
|
|
1815
|
+
from tina4_python.core.router import rbac_authorized
|
|
1816
|
+
if not rbac_authorized(getattr(request, "user", None), _req_roles, _req_perms):
|
|
1817
|
+
response.status(403).json({
|
|
1818
|
+
"error": "Forbidden",
|
|
1819
|
+
"message": "You do not have permission to access this resource",
|
|
1820
|
+
"status": 403,
|
|
1821
|
+
})
|
|
1822
|
+
return True
|
|
1807
1823
|
return False
|
|
1808
1824
|
|
|
1809
1825
|
|
|
@@ -62,16 +62,41 @@ def resolve_connect_timeout() -> float | None:
|
|
|
62
62
|
|
|
63
63
|
|
|
64
64
|
def driver_connect_timeout_seconds(seconds: float | None) -> int | None:
|
|
65
|
-
"""A driver's own connect-timeout option
|
|
65
|
+
"""A driver's own connect-timeout option, in whole seconds, STRICTLY longer
|
|
66
|
+
than the bound it protects.
|
|
66
67
|
|
|
67
68
|
Every driver option here (libpq ``connect_timeout``, mysql-connector
|
|
68
69
|
``connection_timeout``, pymssql ``login_timeout``, pyodbc ``timeout``) is
|
|
69
|
-
whole seconds
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
70
|
+
whole seconds, so the bound has to become an integer — and which integer
|
|
71
|
+
decides whether the driver's deadline lands after ours or on top of it.
|
|
72
|
+
``ceil`` put it on top: for a whole-second bound ``ceil(N) == N``, and the
|
|
73
|
+
shipped default of 10 is whole. ``floor(s) + 1`` is strictly greater for
|
|
74
|
+
every input, fractional or whole, which is what leaves the driver's own
|
|
75
|
+
timer the room to fire first — the thing this wrapper is built on. The
|
|
76
|
+
cost is at most one extra second, on a path that has already failed.
|
|
73
77
|
"""
|
|
74
|
-
return None if seconds is None else max(1, math.
|
|
78
|
+
return None if seconds is None else max(1, math.floor(seconds) + 1)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def bound_was_reached(elapsed_monotonic: float, elapsed_realtime: float,
|
|
82
|
+
seconds: float | None) -> bool:
|
|
83
|
+
"""Did a connect that failed take at least the configured bound?
|
|
84
|
+
|
|
85
|
+
Two readings, because the framework and the driver do not share a clock.
|
|
86
|
+
:func:`connect_deadline` times on ``time.monotonic()``; libpq times its own
|
|
87
|
+
``connect_timeout`` on ``gettimeofday()``. NTP moves the wall clock and
|
|
88
|
+
never touches the monotonic one, so a forward step or slew can make the
|
|
89
|
+
driver abort before a monotonic reading has reached the bound.
|
|
90
|
+
|
|
91
|
+
Taking the LARGER of the two readings covers both directions: the realtime
|
|
92
|
+
reading catches a forward jump, and keeping the monotonic reading means a
|
|
93
|
+
BACKWARD jump cannot hide a timeout that really did happen.
|
|
94
|
+
|
|
95
|
+
Pure, so the decision is testable without faking a clock.
|
|
96
|
+
"""
|
|
97
|
+
if seconds is None:
|
|
98
|
+
return False
|
|
99
|
+
return max(elapsed_monotonic, elapsed_realtime) >= seconds
|
|
75
100
|
|
|
76
101
|
|
|
77
102
|
@contextlib.contextmanager
|
|
@@ -105,19 +130,39 @@ def connect_deadline(host, port):
|
|
|
105
130
|
throws away the driver's own diagnosis, and for the watchdog adapters it
|
|
106
131
|
would abandon a thread the driver could have unwound itself.
|
|
107
132
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
133
|
+
THE INVARIANT, AND WHY IT TAKES TWO CLOCKS
|
|
134
|
+
------------------------------------------
|
|
135
|
+
What decides the race is not the driver's OPTION against our bound — it
|
|
136
|
+
is the driver's ABORT INSTANT against our reading of a clock. Two things
|
|
137
|
+
have to hold, and each of them was broken:
|
|
138
|
+
|
|
139
|
+
* The driver's option must be STRICTLY greater than our bound, so its
|
|
140
|
+
deadline lands after ours instead of on top of it.
|
|
141
|
+
:func:`driver_connect_timeout_seconds` returns ``floor(s) + 1`` for that
|
|
142
|
+
reason; ``ceil`` left a whole-second bound with no separation at all, and
|
|
143
|
+
the default bound is a whole number.
|
|
144
|
+
|
|
145
|
+
* The comparison has to be made on the clock the DRIVER used. libpq times
|
|
146
|
+
``connect_timeout`` with ``gettimeofday()`` — CLOCK_REALTIME — while a
|
|
147
|
+
duration in Python belongs on ``time.monotonic()``. NTP slews and steps
|
|
148
|
+
realtime and never touches monotonic, so a monotonic reading can still
|
|
149
|
+
sit below the bound at the instant the driver has already given up, and
|
|
150
|
+
then the driver's own message — which names no tunable — reaches
|
|
151
|
+
the caller. :func:`bound_was_reached` compares both readings.
|
|
152
|
+
|
|
153
|
+
Break either half and a bare driver message reaches the caller instead of
|
|
154
|
+
ours.
|
|
113
155
|
"""
|
|
114
156
|
seconds = resolve_connect_timeout()
|
|
115
157
|
started = time.monotonic()
|
|
158
|
+
started_realtime = time.time()
|
|
116
159
|
try:
|
|
117
160
|
yield seconds
|
|
118
161
|
except Exception as failure:
|
|
119
|
-
|
|
120
|
-
|
|
162
|
+
elapsed_monotonic = time.monotonic() - started
|
|
163
|
+
elapsed_realtime = time.time() - started_realtime
|
|
164
|
+
elapsed = max(elapsed_monotonic, elapsed_realtime)
|
|
165
|
+
if bound_was_reached(elapsed_monotonic, elapsed_realtime, seconds):
|
|
121
166
|
raise DatabaseConnectTimeout(
|
|
122
167
|
f"Database connect to {host}:{port} timed out after {elapsed:.1f}s "
|
|
123
168
|
f"({CONNECT_TIMEOUT_VARIABLE}={seconds:g} seconds). "
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"use strict";var Tina4=(()=>{var ee=Object.defineProperty;var Xe=Object.getOwnPropertyDescriptor;var Ye=Object.getOwnPropertyNames;var et=Object.prototype.hasOwnProperty;var tt=(e,n)=>{for(var t in n)ee(e,t,{get:n[t],enumerable:!0})},nt=(e,n,t,r)=>{if(n&&typeof n=="object"||typeof n=="function")for(let o of Ye(n))!et.call(e,o)&&o!==t&&ee(e,o,{get:()=>n[o],enumerable:!(r=Xe(n,o))||r.enumerable});return e};var rt=e=>nt(ee({},"__esModule",{value:!0}),e);var $t={};tt($t,{Tina4Element:()=>N,api:()=>Ae,batch:()=>G,clearPersistedKeys:()=>Ve,computed:()=>ye,createI18n:()=>de,effect:()=>E,html:()=>be,i18n:()=>Je,isSignal:()=>I,navigate:()=>Q,persist:()=>ze,pwa:()=>Ne,route:()=>_e,router:()=>xe,rtc:()=>De,rtcConfig:()=>X,signal:()=>w,sse:()=>Pe,ws:()=>K});var L=null,H=null,U=null,B=null;function O(e){B=e}function J(){return B}var me=null,he=null,ve=[],ot=512;var V=0,te=new Set;function w(e,n){let t=e,r=new Set,o={_t4:!0,get value(){if(L&&(r.add(L),H)){let s=L;H.push(()=>r.delete(s))}return t},set value(s){if(Object.is(s,t))return;let i=t;if(t=s,o._debugInfo&&o._debugInfo.updateCount++,he&&he(o,i,s),V>0)for(let c of r)te.add(c);else{let c;for(let a of[...r])try{a()}catch(l){c===void 0&&(c=l)}if(c!==void 0)throw c}},_subscribe(s){return r.add(s),()=>{r.delete(s)}},peek(){return t}};return me?(o._debugInfo={label:n,createdAt:Date.now(),updateCount:0,subs:r},me(o,n)):ve.length<ot&&ve.push({ref:new WeakRef(o),label:n,createdAt:Date.now(),subs:r}),o}function ye(e){let n=w(void 0);return E(()=>{n.value=e()}),{_t4:!0,get value(){return n.value},set value(t){throw new Error("[tina4] computed signals are read-only")},_subscribe(t){return n._subscribe(t)},peek(){return n.peek()}}}function E(e){let n=!1,t=[],r=[],o=()=>{for(let c of r)c();r=[]},s=()=>{if(n)return;for(let g of t)g();t=[],o();let c=L,a=H,l=U;L=s,H=t,U=r;try{e()}finally{L=c,H=a,U=l}};s();let i=()=>{n=!0;for(let c of t)c();t=[],o()};return U&&U.push(i),B&&B.push(i),i}function G(e){V++;try{e()}finally{if(V--,V===0){let n=[...te];te.clear();let t;for(let r of n)try{r()}catch(o){t===void 0&&(t=o)}if(t!==void 0)throw t}}}function I(e){return e!==null&&typeof e=="object"&&e._t4===!0}var Se=new WeakMap,ne="t4:";function be(e,...n){let t=Se.get(e);if(!t){let i=document.createElement("template"),c=new Map,a="";for(let l=0;l<e.length;l++)if(a+=e[l],l<n.length)if(dt(a)){let m=ut(e[l]);m&&c.set(l,m),a+=`__t4_${l}__`}else a+=`<!--${ne}${l}-->`;i.innerHTML=a,t={template:i,propertyNames:c},Se.set(e,t)}let r=t.template.content.cloneNode(!0),o=st(r);for(let{marker:i,index:c}of o)at(i,n[c]);let s=it(r);for(let i of s)ct(i,n,t.propertyNames);return r}function st(e){let n=[];return se(e,t=>{if(t.nodeType===8){let r=t.data;if(r&&r.startsWith(ne)){let o=parseInt(r.slice(ne.length),10);n.push({marker:t,index:o})}}}),n}function it(e){let n=[];return se(e,t=>{t.nodeType===1&&n.push(t)}),n}function se(e,n){let t=e.childNodes;for(let r=0;r<t.length;r++){let o=t[r];n(o),se(o,n)}}function at(e,n){let t=e.parentNode;if(t)if(I(n)){let r=document.createTextNode("");t.replaceChild(r,e),E(()=>{r.data=String(n.value??"")})}else if(typeof n=="function"){let r=document.createComment("");t.replaceChild(r,e);let o=[],s=[];E(()=>{for(let u of s)u();s=[];let i=[],c=J();O(i);let a=n();O(c),s=i;for(let u of o)u.parentNode?.removeChild(u);o=[];let l=oe(a),g=r.parentNode;if(!g)return;let m=Z(g);for(let u of l){let d=m?D(u,m):u;g.insertBefore(d,r),o.push(d)}})}else if(Te(n)){let r=Z(t);if(r){let o=document.createDocumentFragment();for(let s of Array.from(n.childNodes))o.appendChild(D(s,r));t.replaceChild(o,e)}else t.replaceChild(n,e)}else if(n instanceof Node){let r=Z(t);t.replaceChild(r?D(n,r):n,e)}else if(Array.isArray(n)){let r=Z(t),o=document.createDocumentFragment();for(let s of n){let i=oe(s);for(let c of i)o.appendChild(r?D(c,r):c)}t.replaceChild(o,e)}else{let r=document.createTextNode(String(n??""));t.replaceChild(r,e)}}function ct(e,n,t){let r=[];for(let o of Array.from(e.attributes)){let s=o.name,i=o.value;if(s.startsWith("@")){let a=s.slice(1),l=i.match(/__t4_(\d+)__/);if(l){let g=n[parseInt(l[1],10)];typeof g=="function"&&e.addEventListener(a,m=>G(()=>g(m)))}r.push(s);continue}if(s.startsWith("?")){let a=s.slice(1),l=i.match(/__t4_(\d+)__/);if(l){let g=n[parseInt(l[1],10)];if(I(g)){let m=g;E(()=>{m.value?e.setAttribute(a,""):e.removeAttribute(a)})}else typeof g=="function"?E(()=>{g()?e.setAttribute(a,""):e.removeAttribute(a)}):g&&e.setAttribute(a,"")}r.push(s);continue}if(s.startsWith(".")){let a=i.match(/__t4_(\d+)__/);if(a){let l=parseInt(a[1],10),g=t.get(l)??s.slice(1),m=n[l];I(m)?E(()=>{e[g]=m.value}):typeof m=="function"?E(()=>{e[g]=m()??""}):e[g]=m}r.push(s);continue}let c=i.match(/__t4_(\d+)__/);if(c){let a=n[parseInt(c[1],10)];if(I(a)){let l=a;E(()=>{e.setAttribute(s,String(l.value??""))})}else typeof a=="function"?E(()=>{e.setAttribute(s,String(a()??""))}):e.setAttribute(s,String(a??""))}}for(let o of r)e.removeAttribute(o)}var re="http://www.w3.org/2000/svg",lt="http://www.w3.org/1998/Math/MathML",we="http://www.w3.org/1999/xhtml";function Z(e){let n=e;for(;n&&n.nodeType===1;){let t=n,r=t.namespaceURI;if(r===re&&t.localName==="foreignObject")return null;if(r===re||r===lt)return r;if(r===we)return null;n=n.parentNode}return null}function D(e,n){if(e.nodeType!==1)return e;let t=e;if(t.namespaceURI===n){for(let s of Array.from(t.childNodes)){let i=D(s,n);i!==s&&t.replaceChild(i,s)}return t}let r=document.createElementNS(n,t.localName);for(let s of Array.from(t.attributes))r.setAttribute(s.name,s.value);let o=n===re&&t.localName==="foreignObject"?we:n;for(let s of Array.from(t.childNodes))r.appendChild(D(s,o));return r}function ut(e){return e.match(/\.([^\s"'<>/=]+)\s*=\s*["']?$/)?.[1]}function oe(e){if(e==null||e===!1)return[];if(Te(e))return Array.from(e.childNodes);if(e instanceof Node)return[e];if(Array.isArray(e)){let n=[];for(let t of e)n.push(...oe(t));return n}return[document.createTextNode(String(e))]}function Te(e){return e!=null&&typeof e=="object"&&e.nodeType===11}function dt(e){let n=!1,t=!1,r=!1;for(let o=0;o<e.length;o++){if(!r&&e.startsWith("<!--",o)){let i=e.indexOf("-->",o+4);if(i===-1)return!1;o=i+2;continue}let s=e[o];s==="<"&&!n&&!t&&(r=!0),s===">"&&!n&&!t&&(r=!1),r&&(s==='"'&&!n&&(t=!t),s==="'"&&!t&&(n=!n))}return r}var ke=null,Ce=null;var N=class extends HTMLElement{constructor(){super();this._props={};this._rendered=!1;this._disposeRender=null;this._innerDisposers=[];let t=this.constructor;this._root=t.shadow?this.attachShadow({mode:"open"}):this;for(let[r,o]of Object.entries(t.props))this._props[r]=w(this._coerce(this.getAttribute(r),o))}static get observedAttributes(){return Object.keys(this.props)}connectedCallback(){if(this._rendered)return;this._rendered=!0;let t=this.constructor,r=null;if(t.styles&&t.shadow&&this._root instanceof ShadowRoot){let o=document.createElement("style");o.textContent=t.styles,this._root.appendChild(o),r=o}this._disposeRender=E(()=>{this._innerDisposers.splice(0).forEach(a=>a());let o=[],s=J();O(o);let i=this.render();O(s),this._innerDisposers=o;let c=Array.from(this._root.childNodes);for(let a of c)a!==r&&this._root.removeChild(a);i&&this._root.appendChild(i)}),this.onMount(),ke&&ke(this)}disconnectedCallback(){this._disposeRender&&(this._disposeRender(),this._disposeRender=null),this._innerDisposers.splice(0).forEach(t=>t()),this.onUnmount(),Ce&&Ce(this)}attributeChangedCallback(t,r,o){let i=this.constructor.props[t];i&&this._props[t]&&(this._props[t].value=this._coerce(o,i))}prop(t){if(!this._props[t])throw new Error(`[tina4] Prop '${t}' not declared in static props of <${this.tagName.toLowerCase()}>`);return this._props[t]}emit(t,r){this.dispatchEvent(new CustomEvent(t,{bubbles:!0,composed:!0,...r}))}onMount(){}onUnmount(){}_coerce(t,r){return r===Boolean?t!==null:r===Number?t!==null?Number(t):0:t??""}};N.props={},N.styles="",N.shadow=!0;var ae=[],F=null,j="history",ft=!1,W=[],ie=[],Ee=0;function _e(e,n){let t=[],r;e==="*"?r=".*":r=e.replace(/\{(\w+)\}/g,(s,i)=>(t.push(i),"([^/]+)"));let o=new RegExp(`^${r}$`);typeof n=="function"?ae.push({pattern:e,regex:o,paramNames:t,handler:n}):ae.push({pattern:e,regex:o,paramNames:t,handler:n.handler,guard:n.guard})}function Q(e,n){if(j==="hash")if(n?.replace){let t=new URL(location.href);t.hash="#"+e,history.replaceState(null,"",t.toString()),$()}else{let t=new URL(location.href);t.hash="#"+e,history.pushState(null,"",t.toString()),$()}else n?.replace?history.replaceState(null,"",e):history.pushState(null,"",e),$()}function $(){if(!F)return;let e=performance.now(),n=++Ee,t=j==="hash"?location.hash.slice(1)||"/":location.pathname;for(let r of ae){let o=t.match(r.regex);if(!o)continue;let s={};if(r.paramNames.forEach((a,l)=>{s[a]=decodeURIComponent(o[l+1])}),r.guard){let a=r.guard();if(a===!1)return;if(typeof a=="string"){Q(a,{replace:!0});return}}ie.splice(0).forEach(a=>a()),F.innerHTML="";let i=[];O(i);let c=r.handler(s);if(c instanceof Promise)c.then(a=>{if(O(null),n!==Ee){for(let g of i)g();return}Re(F,a),ie=i;let l=performance.now()-e;for(let g of W)g({path:t,params:s,pattern:r.pattern,durationMs:l})});else{O(null),Re(F,c),ie=i;let a=performance.now()-e;for(let l of W)l({path:t,params:s,pattern:r.pattern,durationMs:a})}return}}function Re(e,n){n instanceof DocumentFragment||n instanceof Node?e.replaceChildren(n):typeof n=="string"?e.innerHTML=n:n!=null&&e.replaceChildren(document.createTextNode(String(n)))}var xe={start(e){if(F=document.querySelector(e.target),!F)throw new Error(`[tina4] Router target '${e.target}' not found in DOM`);j=e.mode??"history",ft=!0,window.addEventListener("popstate",$),j==="hash"&&window.addEventListener("hashchange",$),document.addEventListener("click",n=>{if(n.metaKey||n.ctrlKey||n.shiftKey||n.altKey)return;let t=n.target.closest("a[href]");if(!t||t.origin!==location.origin||t.hasAttribute("target")||t.hasAttribute("download")||t.getAttribute("rel")?.includes("external"))return;n.preventDefault();let r=j==="hash"?t.getAttribute("href"):t.pathname;Q(r)}),$()},on(e,n){return W.push(n),()=>{let t=W.indexOf(n);t>=0&&W.splice(t,1)}}};var _={baseUrl:"",auth:!1,tokenKey:"tina4_token",headers:{}},ce=[],le=[],gt=0;function ue(){try{return localStorage.getItem(_.tokenKey)}catch{return null}}function pt(e){try{localStorage.setItem(_.tokenKey,e)}catch{}}function Me(e,n){let t=Object.entries(n).map(([r,o])=>`${encodeURIComponent(r)}=${encodeURIComponent(String(o))}`).join("&");return e+(e.includes("?")?"&":"?")+t}async function Oe(e,n){e._url=n,e._requestId=++gt;for(let c of ce){let a=c(e);a&&(e=a)}let t=await fetch(n,e),r=t.headers.get("FreshToken");r&&pt(r);let o=t.headers.get("Content-Type")??"",s;o.includes("json")?s=await t.json():s=await t.text();let i={status:t.status,data:s,ok:t.ok,headers:t.headers,_requestId:e._requestId};for(let c of le){let a=c(i);a&&(i=a)}if(!t.ok)throw i;return i.data}async function q(e,n,t,r){let o={method:e,credentials:"same-origin",headers:{"Content-Type":"application/json",..._.headers}};if(_.auth){let s=ue();s&&(o.headers.Authorization=`Bearer ${s}`)}if(t!==void 0&&e!=="GET"){let s=typeof t=="object"&&t!==null?{...t}:t;if(_.auth&&typeof s=="object"&&s!==null){let i=ue();i&&(s.formToken=i)}o.body=JSON.stringify(s)}return r?.headers&&Object.assign(o.headers,r.headers),r?.params&&(n=Me(n,r.params)),Oe(o,_.baseUrl+n)}var Ae={configure(e){Object.assign(_,e)},get(e,n){return q("GET",e,void 0,n)},post(e,n,t){return q("POST",e,n,t)},put(e,n,t){return q("PUT",e,n,t)},patch(e,n,t){return q("PATCH",e,n,t)},delete(e,n){return q("DELETE",e,void 0,n)},async graphql(e,n,t,r){return q("POST",e,{query:n,variables:t||{}},r)},async upload(e,n,t){let r={method:"POST",headers:{..._.headers},body:n};if(delete r.headers["Content-Type"],delete r.headers["content-type"],_.auth){let o=ue();o&&(r.headers.Authorization=`Bearer ${o}`)}return t?.headers&&Object.assign(r.headers,t.headers),t?.params&&(e=Me(e,t.params)),Oe(r,_.baseUrl+e)},intercept(e,n){e==="request"?ce.push(n):le.push(n)},_reset(){_.baseUrl="",_.auth=!1,_.tokenKey="tina4_token",_.headers={},ce.length=0,le.length=0}};function mt(e){let n=e.cacheStrategy??"network-first",t=JSON.stringify(e.precache??[]),r=e.offlineRoute?`'${e.offlineRoute}'`:"null";return`
|
|
2
|
+
const CACHE = 'tina4-v1';
|
|
3
|
+
const PRECACHE = ${t};
|
|
4
|
+
const OFFLINE = ${r};
|
|
5
|
+
|
|
6
|
+
self.addEventListener('install', (e) => {
|
|
7
|
+
e.waitUntil(
|
|
8
|
+
caches.open(CACHE).then((c) => c.addAll(PRECACHE)).then(() => self.skipWaiting())
|
|
9
|
+
);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
self.addEventListener('activate', (e) => {
|
|
13
|
+
e.waitUntil(self.clients.claim());
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
self.addEventListener('fetch', (e) => {
|
|
17
|
+
const req = e.request;
|
|
18
|
+
if (req.method !== 'GET') return;
|
|
19
|
+
|
|
20
|
+
${n==="cache-first"?`
|
|
21
|
+
e.respondWith(
|
|
22
|
+
caches.match(req).then((cached) => cached || fetch(req).then((res) => {
|
|
23
|
+
const clone = res.clone();
|
|
24
|
+
caches.open(CACHE).then((c) => c.put(req, clone));
|
|
25
|
+
return res;
|
|
26
|
+
})).catch(() => OFFLINE ? caches.match(OFFLINE) : new Response('Offline', { status: 503 }))
|
|
27
|
+
);`:n==="stale-while-revalidate"?`
|
|
28
|
+
e.respondWith(
|
|
29
|
+
caches.match(req).then((cached) => {
|
|
30
|
+
const fetched = fetch(req).then((res) => {
|
|
31
|
+
caches.open(CACHE).then((c) => c.put(req, res.clone()));
|
|
32
|
+
return res;
|
|
33
|
+
});
|
|
34
|
+
return cached || fetched;
|
|
35
|
+
}).catch(() => OFFLINE ? caches.match(OFFLINE) : new Response('Offline', { status: 503 }))
|
|
36
|
+
);`:`
|
|
37
|
+
e.respondWith(
|
|
38
|
+
fetch(req).then((res) => {
|
|
39
|
+
const clone = res.clone();
|
|
40
|
+
caches.open(CACHE).then((c) => c.put(req, clone));
|
|
41
|
+
return res;
|
|
42
|
+
}).catch(() => caches.match(req).then((cached) =>
|
|
43
|
+
cached || (OFFLINE ? caches.match(OFFLINE) : new Response('Offline', { status: 503 }))
|
|
44
|
+
))
|
|
45
|
+
);`}
|
|
46
|
+
});
|
|
47
|
+
`.trim()}function Ie(e){let n={name:e.name,short_name:e.shortName??e.name,start_url:"/",display:e.display??"standalone",background_color:e.backgroundColor??"#ffffff",theme_color:e.themeColor??"#000000"};return e.icon&&(n.icons=[{src:e.icon,sizes:"192x192",type:"image/png"},{src:e.icon,sizes:"512x512",type:"image/png"}]),n}var Ne={register(e){let n=Ie(e),t=new Blob([JSON.stringify(n)],{type:"application/json"}),r=document.createElement("link");r.rel="manifest",r.href=URL.createObjectURL(t),document.head.appendChild(r);let o=document.querySelector('meta[name="theme-color"]');o||(o=document.createElement("meta"),o.name="theme-color",document.head.appendChild(o)),o.content=e.themeColor??"#000000","serviceWorker"in navigator&&(e.swUrl?navigator.serviceWorker.register(e.swUrl).catch(s=>{console.warn("[tina4] Service worker registration failed:",s)}):navigator.serviceWorker.register("/sw.js").catch(()=>{console.info("[tina4] No service worker at /sw.js. Use pwa.generateServiceWorker() to create one, or pass swUrl in config.")}))},generateServiceWorker(e){return mt(e)},generateManifest(e){return Ie(e)}};var ht={reconnect:!0,reconnectDelay:1e3,reconnectMaxDelay:3e4,reconnectAttempts:1/0,protocols:[],token:""};function vt(e){let n=Array.isArray(e.protocols)?e.protocols:e.protocols?[e.protocols]:[];return e.token?["bearer",e.token,...n]:e.protocols}function yt(e,n={}){let t={...ht,...n},r=w("connecting"),o=w(!1),s=w(null),i=w(null),c=w(0),a={message:[],open:[],close:[],error:[]},l=null,g=!1,m=t.reconnectDelay,u=null,d=0;function f(v){if(typeof v!="string")return v;try{return JSON.parse(v)}catch{return v}}function h(){r.value=d>0?"reconnecting":"connecting";try{l=new WebSocket(e,vt(t))}catch{r.value="closed",o.value=!1;return}l.onopen=()=>{r.value="open",o.value=!0,i.value=null,d=0,m=t.reconnectDelay,c.value=0;for(let v of a.open)v()},l.onmessage=v=>{let T=f(v.data);s.value=T;for(let R of a.message)R(T)},l.onclose=v=>{r.value="closed",o.value=!1;for(let T of a.close)T(v.code,v.reason);!g&&t.reconnect&&d<t.reconnectAttempts&&x()},l.onerror=v=>{i.value=v;for(let T of a.error)T(v)}}function x(){d++,c.value=d,r.value="reconnecting",u=setTimeout(()=>{u=null,h()},m),m=Math.min(m*2,t.reconnectMaxDelay)}let C={status:r,connected:o,lastMessage:s,error:i,reconnectCount:c,send(v){if(!l||l.readyState!==WebSocket.OPEN)throw new Error("[tina4] WebSocket is not connected");let T=typeof v=="string"?v:JSON.stringify(v);l.send(T)},on(v,T){return a[v].push(T),()=>{let R=a[v],A=R.indexOf(T);A>=0&&R.splice(A,1)}},pipe(v,T){let R=A=>{v.value=T(A,v.value)};return C.on("message",R)},close(v,T){g=!0,u&&(clearTimeout(u),u=null),l&&l.close(v??1e3,T??""),r.value="closed",o.value=!1}};return h(),C}var K={connect:yt};var St={mode:"eventsource",method:"GET",headers:{},body:void 0,reconnect:!0,reconnectDelay:1e3,reconnectMaxDelay:3e4,reconnectAttempts:1/0,events:[],json:!0};function bt(e,n={}){let t={...St,...n},r=w("connecting"),o=w(!1),s=w(null),i=w(null),c=w(null),a=w(0),l={message:[],open:[],close:[],error:[]},g=null,m=null,u=!1,d=t.reconnectDelay,f=null,h=0;function x(p){if(!t.json||typeof p!="string")return p;try{return JSON.parse(p)}catch{return p}}function C(p,S){s.value=p,i.value=S;for(let k of l.message)k(p,S??void 0)}function v(){r.value="open",o.value=!0,c.value=null,h=0,d=t.reconnectDelay,a.value=0;for(let p of l.open)p()}function T(){r.value="closed",o.value=!1;for(let p of l.close)p();!u&&t.reconnect&&h<t.reconnectAttempts&&Y()}function R(p){c.value=p;for(let S of l.error)S(p)}function A(){r.value=h>0?"reconnecting":"connecting";try{g=new EventSource(e)}catch{r.value="closed",o.value=!1;return}g.onopen=()=>v(),g.onmessage=p=>{C(x(p.data),null)};for(let p of t.events)g.addEventListener(p,S=>{C(x(S.data),p)});g.onerror=p=>{R(p),g&&g.readyState===2&&(g=null,T())}}function z(){r.value=h>0?"reconnecting":"connecting",m=new AbortController;let p={method:t.method,headers:t.headers,signal:m.signal};t.body!==void 0&&(p.body=typeof t.body=="string"?t.body:JSON.stringify(t.body)),fetch(e,p).then(async S=>{if(!S.ok){R(new Error(`[tina4] SSE fetch ${S.status}`)),T();return}v();let k=S.body.getReader(),M=new TextDecoder,P="";for(;;){let{done:Ge,value:Ze}=await k.read();if(Ge)break;P+=M.decode(Ze,{stream:!0});let ge=P.split(`
|
|
48
|
+
`);P=ge.pop();for(let Qe of ge){let pe=Qe.trim();pe&&C(x(pe),null)}}let fe=P.trim();fe&&C(x(fe),null),m=null,T()}).catch(S=>{S.name!=="AbortError"&&(m=null,R(S),T())})}function Y(){h++,a.value=h,r.value="reconnecting",f=setTimeout(()=>{f=null,b()},d),d=Math.min(d*2,t.reconnectMaxDelay)}function b(){t.mode==="fetch"?z():A()}let y={status:r,connected:o,lastMessage:s,lastEvent:i,error:c,reconnectCount:a,on(p,S){return l[p].push(S),()=>{let k=l[p],M=k.indexOf(S);M>=0&&k.splice(M,1)}},pipe(p,S){let k=M=>{p.value=S(M,p.value)};return y.on("message",k)},close(){u=!0,f&&(clearTimeout(f),f=null),g&&(g.close(),g=null),m&&(m.abort(),m=null),r.value="closed",o.value=!1}};return b(),y}var Pe={connect:bt};async function X(e="/api/rtc/config"){let n=await fetch(e);if(!n.ok)throw new Error(`[tina4] rtc config fetch failed: ${n.status}`);return n.json()}function wt(){let e=window.location;return`${e.protocol==="https:"?"wss:":"ws:"}//${e.host}`}function Le(e){return/^wss?:\/\//.test(e)?e:wt()+(e.startsWith("/")?e:"/"+e)}function Tt(){let e=globalThis.crypto;if(e&&"randomUUID"in e)return e.randomUUID().slice(0,8);let n="";for(let t=0;t<8;t++)n+=Math.floor(16*(.5+t)).toString(16);return n+Date.now().toString(16).slice(-4)}async function kt(e,n={}){let t=w("connecting"),r=w(null),o=w([]),s=w(!1),i=w(null),c=Tt(),a=n.config??await X(n.configUrl),l=n.iceServers??a.iceServers??[],g=n.signallingUrl??a.signalling??"/ws/rtc",m=Le(g.includes("{room}")?g.replace("{room}",e):`${g}/${e}`),u=null;n.media instanceof MediaStream?u=n.media:n.media!==!1&&(u=await navigator.mediaDevices.getUserMedia(n.media??{audio:!0,video:!0})),r.value=u;let d=u?.getVideoTracks()[0]??null,f=new Map,h=K.connect(m);function x(){o.value=[...f.entries()].map(([b,y])=>({id:b,stream:y.stream}))}function C(b){try{h.send({...b,from:c})}catch{}}function v(b){let y=f.get(b);if(y)return y;let p=new RTCPeerConnection({iceServers:l}),S={pc:p,polite:c<b,makingOffer:!1,ignoreOffer:!1,stream:null};if(f.set(b,S),u)for(let k of u.getTracks())p.addTrack(k,u);return p.onnegotiationneeded=async()=>{try{S.makingOffer=!0,await p.setLocalDescription(),C({type:"desc",to:b,description:p.localDescription})}catch(k){i.value=k}finally{S.makingOffer=!1}},p.onicecandidate=({candidate:k})=>{k&&C({type:"ice",to:b,candidate:k})},p.ontrack=({streams:k})=>{S.stream=k[0]??null,x()},p.onconnectionstatechange=()=>{["failed","closed"].includes(p.connectionState)?T(b):p.connectionState==="connected"&&(t.value="connected")},x(),S}function T(b){let y=f.get(b);if(y){try{y.pc.close()}catch{}f.delete(b),x()}}async function R(b){let y=b,p=y.from;if(!p||p===c||y.to&&y.to!==c)return;if(y.type==="hello"){v(p),C({type:"welcome",to:p});return}if(y.type==="welcome"){v(p);return}if(y.type==="bye"){T(p);return}let S=v(p),k=S.pc;if(y.type==="desc"){let M=y.description,P=M.type==="offer"&&(S.makingOffer||k.signalingState!=="stable");if(S.ignoreOffer=!S.polite&&P,S.ignoreOffer)return;await k.setRemoteDescription(M),M.type==="offer"&&(await k.setLocalDescription(),C({type:"desc",to:p,description:k.localDescription}))}else if(y.type==="ice")try{await k.addIceCandidate(y.candidate)}catch(M){S.ignoreOffer||(i.value=M)}}h.on("message",b=>{R(b)}),h.on("open",()=>{C({type:"hello"})});async function A(b){if(b)for(let{pc:y}of f.values()){let p=y.getSenders().find(S=>S.track?.kind==="video");p&&await p.replaceTrack(b)}}async function z(){await A(d),s.value=!1}async function Y(){let y=(await navigator.mediaDevices.getDisplayMedia({video:!0})).getVideoTracks()[0];await A(y),y.onended=()=>{z()},s.value=!0}return{status:t,localStream:r,peers:o,screenSharing:s,error:i,id:c,shareScreen:Y,stopScreen:z,toggleAudio(b){let y=u?.getAudioTracks()[0];return y?(y.enabled=b??!y.enabled,y.enabled):!1},toggleVideo(b){let y=u?.getVideoTracks()[0];return y?(y.enabled=b??!y.enabled,y.enabled):!1},leave(){C({type:"bye"});for(let b of[...f.keys()])T(b);if(u)for(let b of u.getTracks())b.stop();h.close(),t.value="closed"}}}function Ct(e,n={}){let t=w([]),r=w([]),o=w([]),s=new Map,i=n.typingTimeout??3e3,c=n.url??"/ws/chat",a=Le(c.includes("{channel}")?c.replace("{channel}",String(e)):`${c}/${e}`),l=K.connect(a,{token:n.token});function g(d){o.value.includes(d)||(o.value=[...o.value,d]);let f=s.get(d);f&&clearTimeout(f),s.set(d,setTimeout(()=>{o.value=o.value.filter(h=>h!==d),s.delete(d)},i))}l.on("message",d=>{let f=d;switch(f.type){case"message":t.value=[...t.value,f.message];break;case"presence":f.event==="roster"?r.value=f.users??[]:f.event==="join"&&f.user_id?r.value=[...new Set([...r.value,f.user_id])]:f.event==="leave"&&(r.value=r.value.filter(h=>h!==f.user_id));break;case"typing":f.user_id&&g(f.user_id);break}});let m=n.apiBase??"",u=n.messagesPath??"/api/channels/{id}/messages";return{status:l.status,connected:l.connected,messages:t,presence:r,typing:o,send(d,f){l.send({type:"message",body:d,thread_id:f??null})},sendTyping(){l.send({type:"typing"})},markRead(){l.send({type:"read"})},async history(d,f=50){let h=u.replace("{id}",String(e)),x=new URLSearchParams({limit:String(f)});d&&x.set("before",String(d));let C={};n.token&&(C.Authorization=`Bearer ${n.token}`);let v=await fetch(`${m}${h}?${x}`,{headers:C});if(!v.ok)throw new Error(`[tina4] chat history failed: ${v.status}`);let T=await v.json(),R=[...T].reverse();return t.value=[...R,...t.value],T},close(){for(let d of s.values())clearTimeout(d);s.clear(),l.close()}}}async function Et(e,n,t={}){let r=t.filesPath??"/api/files",o=new FormData;o.append("channel_id",String(e)),o.append("file",n,n.name??"file");let s={};t.token&&(s.Authorization=`Bearer ${t.token}`);let i=await fetch(`${t.apiBase??""}${r}`,{method:"POST",body:o,headers:s});if(!i.ok)throw new Error(`[tina4] file upload failed: ${i.status}`);return i.json()}async function Rt(e,n={}){let t=/^https?:\/\//.test(e)?e:`${n.apiBase??""}${n.filesPath??"/api/files"}/${e}`,r={};n.token&&(r.Authorization=`Bearer ${n.token}`);let o=await fetch(t,{headers:r});if(!o.ok)throw new Error(`[tina4] file fetch failed: ${o.status}`);return URL.createObjectURL(await o.blob())}var De={config:X,call:kt,chat:Ct,upload:Et,fetchBlob:Rt};var Fe={read:e=>JSON.parse(e),write:e=>JSON.stringify(e)},$e=/(token|password|passwd|secret|api[_-]?key|apikey|auth(?!or)|credential|jwt|bearer|otp|seed|private[_-]?key|session[_-]?id)/i,_t=/^[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}$/,xt=/^[A-Za-z0-9+/_=-]{40,}$/,qe=new Set;function Mt(e,n){if($e.test(e))return`key name "${e}" looks like a credential`;if(typeof n=="string"){if(_t.test(n))return"value looks like a JWT";if(n.length>=40&&xt.test(n))return"value looks like a long base64 / token"}if(n&&typeof n=="object"&&!Array.isArray(n)){for(let t of Object.keys(n))if($e.test(t))return`object contains a credential-shape field "${t}"`}return null}function Ot(e,n){qe.has(n)||(qe.add(n),console.warn(`[tina4 persist] ${e} (key: ${JSON.stringify(n)}). localStorage is XSS-readable and never appropriate for credentials, tokens, passwords, personal data, or secrets. See STORAGE.md.`))}function He(e){if(typeof globalThis>"u")return null;try{let n=e==="local"?globalThis.localStorage:globalThis.sessionStorage;return!n||typeof n.getItem!="function"?null:n}catch{return null}}function je(e,n,t){try{let r=JSON.parse(e);return r&&typeof r=="object"&&"value"in r?{version:r.v,payload:r.value}:{version:void 0,payload:r}}catch{return{version:void 0,payload:t?e:n.read(e)}}}function We(e,n,t){return t?e:n.read(typeof e=="string"?e:JSON.stringify(e))}function At(e,n,t,r,o,s,i){try{let c=n.getItem(t);if(c===null)return;let a=je(c,o,s);if(a.version===r||a.version===void 0){e.value=We(a.payload,o,s);return}if(i){try{e.value=i(a.payload,a.version)}catch(l){console.warn(`[tina4 persist] migrate() threw for key "${t}":`,l)}return}console.warn(`[tina4 persist] stored version ${a.version} does not match current ${r} for key "${t}", and no migrate() was provided. Discarding the stored value.`)}catch(c){console.warn(`[tina4 persist] failed to read key "${t}":`,c)}}function Ke(e,n,t){if(t)return;let r=Mt(e,n);r&&Ot(r,e)}function It(e,n,t,r,o,s,i){return E(()=>{let c=e.value;Ke(t,c,i);try{let a=JSON.stringify(s?{v:r,value:c}:{v:r,value:o.write(c)});n.setItem(t,a)}catch(a){console.warn(`[tina4 persist] failed to write key "${t}":`,a)}})}function Nt(e,n,t,r,o,s,i,c){if(!c||typeof globalThis>"u"||!("addEventListener"in globalThis))return null;let a=l=>{let g=l;if(!(g.storageArea!==n||g.key!==t||g.newValue===null))try{let m=je(g.newValue,o,s);m.version!==void 0&&m.version!==r&&i?e.value=i(m.payload,m.version):(m.version===r||m.version===void 0)&&(e.value=We(m.payload,o,s))}catch(m){console.warn(`[tina4 persist] failed to parse storage event for key "${t}":`,m)}};return globalThis.addEventListener?.("storage",a),()=>{globalThis.removeEventListener?.("storage",a)}}function Pt(e,n){try{e.removeItem(n)}catch(t){console.warn(`[tina4 persist] failed to clear key "${n}":`,t)}}function ze(e,n){let{key:t,storage:r="local",serializer:o=Fe,version:s=1,migrate:i,syncTabs:c=!1,silenceCredentialWarning:a=!1}=n;if(!t||typeof t!="string")throw new Error("[tina4 persist] options.key is required and must be a string");let l=o===Fe,g=He(r);if(!g)return Ue(e,()=>{},()=>{});At(e,g,t,s,o,l,i),Ke(t,e.peek(),a);let m=It(e,g,t,s,o,l,a),u=Nt(e,g,t,s,o,l,i,c);return Ue(e,()=>Pt(g,t),()=>{m(),u&&u()})}function Ue(e,n,t){return Object.assign(e,{clear:n,dispose:t})}function Ve(e,n="local"){let t=He(n);if(t)for(let r of e)try{t.removeItem(r)}catch(o){console.warn(`[tina4 persist] failed to clear key "${r}":`,o)}}var Lt=["ar","he","fa","ur","ps","dv","syr","ckb","yi"];function Dt(){return globalThis.navigator?.language||"en"}function Be(e,n="",t={}){for(let[r,o]of Object.entries(e)){let s=n?`${n}.${r}`:r;if(o!==null&&typeof o=="object"&&!Array.isArray(o))Be(o,s,t);else{let i=String(o);t[s]=i,r in t||(t[r]=i)}}return t}function Ft(e,n){return e.replace(/\{(\w+)\}/g,(t,r)=>Object.prototype.hasOwnProperty.call(n,r)?String(n[r]):t)}function de(e={}){let n=e.locale||Dt(),t=e.fallbackLocale||n,r=new Set([...Lt,...e.rtlLocales||[]]),o=w(n,"i18n.locale"),s=new Map,i=new Map;function c(u,d){let f=Be(d),h=s.get(u);s.set(u,h?{...h,...f}:f)}if(e.messages)for(let[u,d]of Object.entries(e.messages))c(u,d);function a(u,d){return s.get(u)?.[d]}function l(u,d){let f=`n|${u}|${JSON.stringify(d||{})}`,h=i.get(f);return h||(h=new Intl.NumberFormat(u,d),i.set(f,h)),h}function g(u,d){let f=`d|${u}|${JSON.stringify(d||{})}`,h=i.get(f);return h||(h=new Intl.DateTimeFormat(u,d),i.set(f,h)),h}function m(u,d){let f=`r|${u}|${JSON.stringify(d||{})}`,h=i.get(f);return h||(h=new Intl.RelativeTimeFormat(u,d),i.set(f,h)),h}return{locale:o,t(u,d){let f=o.value,h=a(f,u);return h===void 0&&t!==f&&(h=a(t,u)),h===void 0&&(h=u),d?Ft(h,d):h},setLocale(u){o.value=u},getLocale(){return o.value},addMessages:c,hasLocale(u){return s.has(u)},availableLocales(){return[...s.keys()].sort()},async loadMessages(u,d){let f=await fetch(d);if(!f.ok)throw new Error(`[tina4 i18n] failed to load "${u}" from ${d}: ${f.status}`);c(u,await f.json())},number(u,d){return l(o.value,d).format(u)},currency(u,d,f){return l(o.value,{style:"currency",currency:d,...f}).format(u)},date(u,d){let f=u instanceof Date?u:new Date(u);return g(o.value,d).format(f)},relativeTime(u,d,f){return m(o.value,f||{numeric:"auto"}).format(u,d)},isRTL(){return r.has(o.value.split("-")[0].toLowerCase())},dir(){return this.isRTL()?"rtl":"ltr"}}}var Je=de();return rt($t);})();
|
|
@@ -356,6 +356,16 @@ class ServiceRunner:
|
|
|
356
356
|
|
|
357
357
|
for svc in self.services:
|
|
358
358
|
svc["running"] = False
|
|
359
|
+
instance = svc.get("instance")
|
|
360
|
+
if instance is not None and hasattr(instance, "stop") and callable(instance.stop):
|
|
361
|
+
try:
|
|
362
|
+
instance.stop()
|
|
363
|
+
except Exception as exc:
|
|
364
|
+
_get_log().error(
|
|
365
|
+
"Error stopping service instance",
|
|
366
|
+
name=svc.get("name"),
|
|
367
|
+
error=str(exc),
|
|
368
|
+
)
|
|
359
369
|
|
|
360
370
|
# Give threads a moment to finish
|
|
361
371
|
for t in self._threads:
|