tina4ruby 3.13.137 → 3.13.139
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +40 -0
- data/COMMERCIAL-LICENSE.md +8 -0
- data/LICENSE +373 -0
- data/LICENSE.txt +373 -21
- data/NOTICE +11 -0
- data/README.md +11 -7
- data/exe/tina4ruby +6 -0
- data/lib/tina4/ai.rb +6 -0
- data/lib/tina4/ai_client.rb +10 -3
- data/lib/tina4/api.rb +74 -8
- data/lib/tina4/auth.rb +108 -26
- data/lib/tina4/auto_crud.rb +83 -27
- data/lib/tina4/background.rb +7 -1
- data/lib/tina4/base64.rb +59 -0
- data/lib/tina4/cache.rb +6 -0
- data/lib/tina4/cache_backends/base_backend.rb +14 -0
- data/lib/tina4/cache_backends/database_backend.rb +6 -0
- data/lib/tina4/cache_backends/file_backend.rb +6 -0
- data/lib/tina4/cache_backends/memcached_backend.rb +6 -0
- data/lib/tina4/cache_backends/memory_backend.rb +6 -0
- data/lib/tina4/cache_backends/mongo_backend.rb +22 -1
- data/lib/tina4/cache_backends/redis_backend.rb +6 -0
- data/lib/tina4/cache_backends/valkey_backend.rb +6 -0
- data/lib/tina4/cache_backends.rb +9 -1
- data/lib/tina4/cli.rb +27 -7
- data/lib/tina4/constants.rb +12 -8
- data/lib/tina4/container.rb +6 -0
- data/lib/tina4/context/chunker.rb +6 -0
- data/lib/tina4/context.rb +13 -5
- data/lib/tina4/cors.rb +66 -10
- data/lib/tina4/crud.rb +78 -9
- data/lib/tina4/database/sqlite3_adapter.rb +9 -1
- data/lib/tina4/database.rb +260 -44
- data/lib/tina4/database_adapter.rb +6 -0
- data/lib/tina4/database_result.rb +6 -0
- data/lib/tina4/database_url.rb +6 -0
- data/lib/tina4/debug.rb +6 -0
- data/lib/tina4/dev.rb +6 -0
- data/lib/tina4/dev_admin.rb +222 -89
- data/lib/tina4/dev_mailbox.rb +6 -1
- data/lib/tina4/dispatch_pipeline.rb +86 -1
- data/lib/tina4/docs.rb +6 -0
- data/lib/tina4/docstore.rb +23 -4
- data/lib/tina4/drivers/arango_graph_driver.rb +8 -1
- data/lib/tina4/drivers/bolt_graph_driver.rb +6 -0
- data/lib/tina4/drivers/firebird_driver.rb +95 -6
- data/lib/tina4/drivers/mongodb_driver.rb +6 -0
- data/lib/tina4/drivers/mssql_driver.rb +54 -39
- data/lib/tina4/drivers/mysql_driver.rb +6 -0
- data/lib/tina4/drivers/odbc_driver.rb +6 -0
- data/lib/tina4/drivers/postgres_driver.rb +35 -8
- data/lib/tina4/drivers/schema_split.rb +6 -0
- data/lib/tina4/drivers/sqlite_driver.rb +16 -1
- data/lib/tina4/drivers/ultipa_graph_driver.rb +6 -0
- data/lib/tina4/env.rb +14 -6
- data/lib/tina4/error_overlay.rb +6 -0
- data/lib/tina4/events.rb +6 -0
- data/lib/tina4/feedback.rb +9 -2
- data/lib/tina4/field_types.rb +6 -0
- data/lib/tina4/form_parser.rb +241 -0
- data/lib/tina4/frond.rb +200 -58
- data/lib/tina4/gallery/auth/src/routes/api/gallery_auth.rb +6 -0
- data/lib/tina4/gallery/database/src/routes/api/gallery_db.rb +6 -0
- data/lib/tina4/gallery/error-overlay/src/routes/api/gallery_crash.rb +6 -0
- data/lib/tina4/gallery/orm/src/routes/api/gallery_products.rb +6 -0
- data/lib/tina4/gallery/queue/src/routes/api/gallery_queue.rb +6 -0
- data/lib/tina4/gallery/rest-api/src/routes/api/gallery_hello.rb +6 -0
- data/lib/tina4/gallery/templates/src/routes/gallery_page.rb +6 -0
- data/lib/tina4/graph.rb +6 -0
- data/lib/tina4/graphql.rb +134 -35
- data/lib/tina4/health.rb +6 -0
- data/lib/tina4/html_element.rb +6 -0
- data/lib/tina4/http_server.rb +690 -0
- data/lib/tina4/imap_client.rb +352 -0
- data/lib/tina4/import_helper.rb +6 -0
- data/lib/tina4/job.rb +21 -2
- data/lib/tina4/localization.rb +6 -0
- data/lib/tina4/log.rb +6 -0
- data/lib/tina4/mail_socket.rb +124 -0
- data/lib/tina4/mcp.rb +18 -9
- data/lib/tina4/messenger.rb +86 -55
- data/lib/tina4/metrics.rb +6 -0
- data/lib/tina4/middleware.rb +27 -10
- data/lib/tina4/migration.rb +6 -0
- data/lib/tina4/model_collection.rb +6 -0
- data/lib/tina4/mqtt.rb +9 -2
- data/lib/tina4/mqtt_message.rb +6 -0
- data/lib/tina4/orm.rb +72 -16
- data/lib/tina4/parse_json.rb +25 -0
- data/lib/tina4/plan.rb +9 -2
- data/lib/tina4/point.rb +6 -0
- data/lib/tina4/port_takeover.rb +6 -0
- data/lib/tina4/project_index.rb +6 -0
- data/lib/tina4/public/THIRD-PARTY-NOTICES.md +55 -0
- data/lib/tina4/public/bundled-component-provenance.json +48 -0
- data/lib/tina4/public/js/tina4js.min.js +13 -3
- data/lib/tina4/public/licenses/swagger-ui-3.10.0-original-notice.txt +11 -0
- data/lib/tina4/public/licenses/swagger-ui-5.17.14-NOTICE.txt +2 -0
- data/lib/tina4/public/licenses/swagger-ui-Apache-2.0.txt +202 -0
- data/lib/tina4/public/licenses/tina4-css-MIT.txt +21 -0
- data/lib/tina4/public/licenses/tina4-dev-admin-components.json +240 -0
- data/lib/tina4/public/licenses/tina4-dev-admin-third-party.txt +919 -0
- data/lib/tina4/public/licenses/tina4-js-MIT.txt +20 -0
- data/lib/tina4/push.rb +22 -4
- data/lib/tina4/query_builder.rb +6 -0
- data/lib/tina4/queue.rb +6 -0
- data/lib/tina4/queue_backends/kafka_backend.rb +18 -2
- data/lib/tina4/queue_backends/lite_backend.rb +18 -0
- data/lib/tina4/queue_backends/mongo_backend.rb +18 -0
- data/lib/tina4/queue_backends/rabbitmq_backend.rb +18 -2
- data/lib/tina4/rack_app.rb +62 -50
- data/lib/tina4/rate_limiter.rb +6 -0
- data/lib/tina4/realtime/attachment.rb +6 -0
- data/lib/tina4/realtime/channel.rb +6 -0
- data/lib/tina4/realtime/channel_member.rb +6 -0
- data/lib/tina4/realtime/local_storage.rb +6 -0
- data/lib/tina4/realtime/message.rb +6 -0
- data/lib/tina4/realtime/s3_storage.rb +15 -1
- data/lib/tina4/realtime/storage.rb +6 -0
- data/lib/tina4/realtime/storage_backend.rb +6 -0
- data/lib/tina4/realtime/workspace.rb +6 -0
- data/lib/tina4/realtime.rb +10 -3
- data/lib/tina4/request.rb +90 -127
- data/lib/tina4/response.rb +71 -9
- data/lib/tina4/response_cache.rb +6 -0
- data/lib/tina4/router.rb +6 -0
- data/lib/tina4/secret_file.rb +34 -0
- data/lib/tina4/seeder.rb +9 -3
- data/lib/tina4/service.rb +8 -2
- data/lib/tina4/service_runner.rb +6 -0
- data/lib/tina4/session.rb +6 -0
- data/lib/tina4/session_handlers/database_handler.rb +42 -12
- data/lib/tina4/session_handlers/file_handler.rb +6 -0
- data/lib/tina4/session_handlers/memcached_handler.rb +6 -0
- data/lib/tina4/session_handlers/mongo_handler.rb +108 -39
- data/lib/tina4/session_handlers/mongo_wire_client.rb +23 -9
- data/lib/tina4/session_handlers/redis_handler.rb +6 -0
- data/lib/tina4/session_handlers/resp_client.rb +6 -0
- data/lib/tina4/session_handlers/valkey_handler.rb +6 -0
- data/lib/tina4/shutdown.rb +17 -14
- data/lib/tina4/smtp_client.rb +222 -0
- data/lib/tina4/sql_translator.rb +93 -0
- data/lib/tina4/sqlite3_gem.rb +27 -0
- data/lib/tina4/sso.rb +52 -12
- data/lib/tina4/ssrf.rb +148 -0
- data/lib/tina4/swagger.rb +6 -0
- data/lib/tina4/template.rb +6 -0
- data/lib/tina4/test.rb +8 -2
- data/lib/tina4/test_client.rb +8 -1
- data/lib/tina4/testing.rb +8 -1
- data/lib/tina4/validator.rb +6 -0
- data/lib/tina4/version.rb +7 -1
- data/lib/tina4/webserver.rb +25 -143
- data/lib/tina4/websocket.rb +170 -20
- data/lib/tina4/websocket_backplane.rb +25 -2
- data/lib/tina4/wsdl.rb +54 -18
- data/lib/tina4/xml_parser.rb +268 -0
- data/lib/tina4.rb +98 -33
- data/lib/tina4ruby.rb +6 -0
- metadata +44 -140
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
|
+
# Copyright (c) 2026 Code Infinity
|
|
3
|
+
# SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
# This Source Code Form is subject to the terms of the Mozilla Public
|
|
5
|
+
# License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
6
|
+
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
7
|
+
|
|
2
8
|
|
|
3
9
|
require "json"
|
|
4
10
|
|
|
@@ -211,25 +217,49 @@ module Tina4
|
|
|
211
217
|
# spells "already exists" differently ("There is already an object named",
|
|
212
218
|
# "already exists", SQLSTATE 42S01) and a string match would rot the first
|
|
213
219
|
# time an engine reworded itself or ran under another locale.
|
|
220
|
+
#
|
|
221
|
+
# The re-check is RETRIED, a bounded number of times, because the loser
|
|
222
|
+
# can fail BEFORE the winner has committed. MEASURED on MySQL 8.4: CREATE
|
|
223
|
+
# TABLE IF NOT EXISTS takes a shared metadata lock on the name, checks,
|
|
224
|
+
# then upgrades it to exclusive, and two sessions upgrading at once is a
|
|
225
|
+
# metadata-lock deadlock. MySQL backs the victim off silently only when the
|
|
226
|
+
# session holds no other metadata lock; otherwise the victim gets 1213
|
|
227
|
+
# "Deadlock found" while the winner's table is still invisible, and one
|
|
228
|
+
# immediate re-check turned that lost race into a failed request (tina4-php
|
|
229
|
+
# CI run 35972320442). Same attempts and delays in all four frameworks.
|
|
230
|
+
CREATE_ATTEMPTS = 5
|
|
231
|
+
CREATE_RETRY_DELAY = 0.05
|
|
232
|
+
|
|
214
233
|
def ensure_table
|
|
215
234
|
return if @table_ready
|
|
216
235
|
|
|
217
236
|
@table_ready = true
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
rescue StandardError
|
|
221
|
-
# A failed statement leaves PostgreSQL's transaction ABORTED, so
|
|
222
|
-
# without this the re-check would fail for the wrong reason and report
|
|
223
|
-
# a missing table that is right there. Best effort: an engine with no
|
|
224
|
-
# open transaction is entitled to object to being rolled back.
|
|
237
|
+
attempt = 1
|
|
238
|
+
until db.table_exists?(TABLE_NAME)
|
|
225
239
|
begin
|
|
226
|
-
db.
|
|
240
|
+
db.execute(create_table_sql)
|
|
241
|
+
return
|
|
227
242
|
rescue StandardError
|
|
228
|
-
|
|
243
|
+
# A failed statement leaves PostgreSQL's transaction ABORTED, so
|
|
244
|
+
# without this the re-check would fail for the wrong reason and
|
|
245
|
+
# report a missing table that is right there. Best effort: an engine
|
|
246
|
+
# with no open transaction is entitled to object to being rolled back.
|
|
247
|
+
begin
|
|
248
|
+
db.rollback
|
|
249
|
+
rescue StandardError
|
|
250
|
+
nil
|
|
251
|
+
end
|
|
252
|
+
# Somebody else created it - that is the race, and it is a success.
|
|
253
|
+
# Still absent after the last attempt is a real failure, re-raised
|
|
254
|
+
# untouched.
|
|
255
|
+
if attempt >= CREATE_ATTEMPTS
|
|
256
|
+
return if db.table_exists?(TABLE_NAME)
|
|
257
|
+
|
|
258
|
+
raise
|
|
259
|
+
end
|
|
260
|
+
sleep(CREATE_RETRY_DELAY * attempt)
|
|
261
|
+
attempt += 1
|
|
229
262
|
end
|
|
230
|
-
# Somebody else created it - that is the race, and it is a success.
|
|
231
|
-
# Anything else is a real failure and is re-raised untouched.
|
|
232
|
-
raise unless db.table_exists?(TABLE_NAME)
|
|
233
263
|
end
|
|
234
264
|
end
|
|
235
265
|
end
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
|
+
# Copyright (c) 2026 Code Infinity
|
|
3
|
+
# SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
# This Source Code Form is subject to the terms of the Mozilla Public
|
|
5
|
+
# License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
6
|
+
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
7
|
+
|
|
2
8
|
require "json"
|
|
3
9
|
require "fileutils"
|
|
4
10
|
require "digest"
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
|
+
# Copyright (c) 2026 Code Infinity
|
|
3
|
+
# SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
# This Source Code Form is subject to the terms of the Mozilla Public
|
|
5
|
+
# License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
6
|
+
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
7
|
+
|
|
2
8
|
|
|
3
9
|
require "digest"
|
|
4
10
|
require "json"
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
|
+
# Copyright (c) 2026 Code Infinity
|
|
3
|
+
# SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
# This Source Code Form is subject to the terms of the Mozilla Public
|
|
5
|
+
# License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
6
|
+
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
7
|
+
|
|
2
8
|
require "json"
|
|
3
9
|
require_relative "mongo_wire_client"
|
|
4
10
|
|
|
@@ -51,36 +57,90 @@ module Tina4
|
|
|
51
57
|
@database = options[:database] || ENV["TINA4_SESSION_MONGO_DB"] || "tina4"
|
|
52
58
|
@collection_name = options[:collection] || ENV["TINA4_SESSION_MONGO_COLLECTION"] || "sessions"
|
|
53
59
|
@gem_available = gem_available?
|
|
54
|
-
@client = nil
|
|
55
|
-
@wire_client = nil
|
|
56
|
-
@collection = nil
|
|
57
|
-
@index_ready = false
|
|
58
60
|
end
|
|
59
61
|
|
|
60
|
-
#
|
|
62
|
+
# ONE TRANSPORT PER PROCESS (issue #136, parity with tina4-python #136).
|
|
61
63
|
#
|
|
62
|
-
#
|
|
63
|
-
#
|
|
64
|
-
#
|
|
65
|
-
#
|
|
66
|
-
#
|
|
67
|
-
#
|
|
68
|
-
#
|
|
69
|
-
#
|
|
70
|
-
#
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
64
|
+
# Every request builds a Session and every Session builds a NEW handler,
|
|
65
|
+
# so a transport owned by the handler is a transport per REQUEST. The
|
|
66
|
+
# handler used to open its own Mongo::Client on first use - each with its
|
|
67
|
+
# own SDAM monitor threads and connection pool - and nothing ever closed
|
|
68
|
+
# it. MEASURED on the lab: 21 requests left 87 extra threads behind; on the
|
|
69
|
+
# zero-dependency path 40 sessions left 40 sockets open to MongoDB.
|
|
70
|
+
#
|
|
71
|
+
# A Mongo::Client is thread-safe and pools internally, so the driver
|
|
72
|
+
# expects exactly one per (uri, database) - the same shape
|
|
73
|
+
# Tina4::DocStore.mongo_client already uses. The zero-dependency
|
|
74
|
+
# MongoWireClient is bound to one collection and serialises its commands
|
|
75
|
+
# on its single socket, so it is shared per (uri, database, collection).
|
|
76
|
+
# Handlers hold no transport of their own: they look it up here on every
|
|
77
|
+
# operation, so a released transport is simply rebuilt on next use.
|
|
78
|
+
@shared = {}
|
|
79
|
+
@index_ready = {}
|
|
80
|
+
@shared_lock = Mutex.new
|
|
81
|
+
|
|
82
|
+
class << self
|
|
83
|
+
# The shared transport for +key+, built by the block on first use. The
|
|
84
|
+
# double-checked lock stops two threads racing the first request from
|
|
85
|
+
# both building a client and orphaning one - the same leak, just rarer.
|
|
86
|
+
def shared_transport(key)
|
|
87
|
+
transport = @shared[key]
|
|
88
|
+
return transport if transport
|
|
89
|
+
|
|
90
|
+
@shared_lock.synchronize { @shared[key] ||= yield }
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# True exactly once per process for +key+ - used so the TTL index round
|
|
94
|
+
# trip runs once per (store, ttl), not once per request.
|
|
95
|
+
def claim_index(key)
|
|
96
|
+
@shared_lock.synchronize do
|
|
97
|
+
next false if @index_ready[key]
|
|
98
|
+
|
|
99
|
+
@index_ready[key] = true
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Close and forget the shared transport for +key+ (MongoHandler#close).
|
|
104
|
+
def release_shared(key)
|
|
105
|
+
close_transport(@shared_lock.synchronize { @shared.delete(key) })
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Close every shared session transport - process shutdown, or a test
|
|
109
|
+
# that must leave no connection behind. Safe to call at any time: the
|
|
110
|
+
# next session operation simply reconnects.
|
|
111
|
+
def close_shared_clients
|
|
112
|
+
transports = @shared_lock.synchronize do
|
|
113
|
+
values = @shared.values
|
|
114
|
+
@shared.clear
|
|
115
|
+
@index_ready.clear
|
|
116
|
+
values
|
|
117
|
+
end
|
|
118
|
+
transports.each { |transport| close_transport(transport) }
|
|
119
|
+
true
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
private
|
|
123
|
+
|
|
124
|
+
# A close failure must never mask the caller's work.
|
|
125
|
+
def close_transport(transport)
|
|
75
126
|
transport&.close
|
|
76
127
|
rescue StandardError
|
|
77
128
|
nil
|
|
78
129
|
end
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Release the transport this handler's configuration uses.
|
|
133
|
+
#
|
|
134
|
+
# The transport is SHARED per process (see .shared_transport), so this
|
|
135
|
+
# closes it for every handler pointed at the same store - exactly what an
|
|
136
|
+
# owner that is done with the store wants, and harmless to the rest: they
|
|
137
|
+
# look the transport up on every operation and reconnect on next use. The
|
|
138
|
+
# Mongo::Client (a pool of real sockets) and the zero-dependency socket
|
|
139
|
+
# are both closed this way, so neither path leaks. Safe to call on a
|
|
140
|
+
# handler that never connected. Parity with the Python master's
|
|
141
|
+
# MongoDBSessionHandler.close() and Tina4::DocStore.close_doc_store.
|
|
142
|
+
def close
|
|
143
|
+
self.class.release_shared(transport_key)
|
|
84
144
|
end
|
|
85
145
|
|
|
86
146
|
# Decide whether a stored document has expired, FROM THE DOCUMENT ALONE.
|
|
@@ -189,17 +249,25 @@ module Tina4
|
|
|
189
249
|
# handler takes the other. A per-operation branch is a branch that can be
|
|
190
250
|
# wrong on one path only - which is exactly how a fallback ships untested.
|
|
191
251
|
def collection
|
|
192
|
-
|
|
252
|
+
if @gem_available
|
|
253
|
+
client = self.class.shared_transport(transport_key) { Mongo::Client.new(@uri, database: @database) }
|
|
254
|
+
collection = client[@collection_name]
|
|
255
|
+
ensure_ttl_index(collection)
|
|
256
|
+
collection
|
|
257
|
+
else
|
|
258
|
+
self.class.shared_transport(transport_key) { build_wire_client }
|
|
259
|
+
end
|
|
260
|
+
end
|
|
193
261
|
|
|
262
|
+
# The registry key for this handler's transport: one Mongo::Client per
|
|
263
|
+
# (uri, database); one wire client per (uri, database, collection), since
|
|
264
|
+
# a MongoWireClient is bound to a single collection.
|
|
265
|
+
def transport_key
|
|
194
266
|
if @gem_available
|
|
195
|
-
|
|
196
|
-
@collection = @client[@collection_name]
|
|
197
|
-
ensure_ttl_index
|
|
267
|
+
[:gem, @uri, @database]
|
|
198
268
|
else
|
|
199
|
-
@
|
|
200
|
-
@collection = @wire_client
|
|
269
|
+
[:wire, @uri, @database, @collection_name]
|
|
201
270
|
end
|
|
202
|
-
@collection
|
|
203
271
|
end
|
|
204
272
|
|
|
205
273
|
# The zero-dependency transport, pointed at the host and port parsed out of
|
|
@@ -258,18 +326,19 @@ module Tina4
|
|
|
258
326
|
# does not exist when the gem is absent, so evaluating them would raise
|
|
259
327
|
# NameError rather than the LoadError anyone would expect.
|
|
260
328
|
#
|
|
261
|
-
# Runs ONCE per
|
|
262
|
-
#
|
|
263
|
-
#
|
|
264
|
-
|
|
265
|
-
|
|
329
|
+
# Runs ONCE per process for each (store, collection, ttl), on the first
|
|
330
|
+
# operation - not once per handler, which is once per REQUEST (issue
|
|
331
|
+
# #136). The claim is taken BEFORE the round trip so an unreachable server
|
|
332
|
+
# is not re-probed on every read - the same ordering the Python master
|
|
333
|
+
# uses for _table_ready.
|
|
334
|
+
def ensure_ttl_index(collection)
|
|
335
|
+
return unless self.class.claim_index([@uri, @database, @collection_name, @ttl])
|
|
266
336
|
|
|
267
|
-
|
|
268
|
-
@collection.indexes.create_one({ updated_at: 1 }, expire_after_seconds: @ttl)
|
|
337
|
+
collection.indexes.create_one({ updated_at: 1 }, expire_after_seconds: @ttl)
|
|
269
338
|
rescue Mongo::Error::OperationFailure => e
|
|
270
339
|
raise unless e.code == 85 || e.message.include?("IndexOptionsConflict")
|
|
271
|
-
|
|
272
|
-
|
|
340
|
+
collection.indexes.drop_one("updated_at_1")
|
|
341
|
+
collection.indexes.create_one({ updated_at: 1 }, expire_after_seconds: @ttl)
|
|
273
342
|
rescue Mongo::Error => e
|
|
274
343
|
# The index is the background REAPER, not the expiry authority - #read
|
|
275
344
|
# checks expires_at on the document itself - so failing to create it must
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
|
+
# Copyright (c) 2026 Code Infinity
|
|
3
|
+
# SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
# This Source Code Form is subject to the terms of the Mozilla Public
|
|
5
|
+
# License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
6
|
+
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
7
|
+
|
|
2
8
|
|
|
3
9
|
require "socket"
|
|
4
10
|
|
|
@@ -71,6 +77,10 @@ module Tina4
|
|
|
71
77
|
@timeout = timeout
|
|
72
78
|
@socket = nil
|
|
73
79
|
@request_id = 0
|
|
80
|
+
# One socket carries one request/reply at a time. The client is SHARED
|
|
81
|
+
# by every session in the process (MongoHandler.shared_transport, issue
|
|
82
|
+
# #136), so concurrent requests must not interleave their frames.
|
|
83
|
+
@lock = Mutex.new
|
|
74
84
|
end
|
|
75
85
|
|
|
76
86
|
# Documents matching +filter+, capped at one.
|
|
@@ -142,15 +152,19 @@ module Tina4
|
|
|
142
152
|
# transport failure, so the socket is dropped and the next command
|
|
143
153
|
# reconnects rather than inheriting the damage.
|
|
144
154
|
def exchange(document)
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
155
|
+
@lock.synchronize do
|
|
156
|
+
socket = connection
|
|
157
|
+
@request_id += 1
|
|
158
|
+
body = "\x00\x00\x00\x00".b + "\x00".b + encode_document(document) # flagBits(4) + section kind 0
|
|
159
|
+
header = [16 + body.bytesize, @request_id, 0, OP_MSG].pack("V4")
|
|
160
|
+
socket.write(header + body)
|
|
161
|
+
read_reply(socket)
|
|
162
|
+
rescue StandardError => e
|
|
163
|
+
# Still under the lock: no other session can be mid-exchange on the
|
|
164
|
+
# socket being dropped. The next command reconnects.
|
|
165
|
+
close
|
|
166
|
+
raise MongoWireError, "MongoDB transport failed: #{e.message}"
|
|
167
|
+
end
|
|
154
168
|
end
|
|
155
169
|
|
|
156
170
|
def connection
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
|
+
# Copyright (c) 2026 Code Infinity
|
|
3
|
+
# SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
# This Source Code Form is subject to the terms of the Mozilla Public
|
|
5
|
+
# License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
6
|
+
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
7
|
+
|
|
2
8
|
require "json"
|
|
3
9
|
require_relative "resp_client"
|
|
4
10
|
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
|
+
# Copyright (c) 2026 Code Infinity
|
|
3
|
+
# SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
# This Source Code Form is subject to the terms of the Mozilla Public
|
|
5
|
+
# License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
6
|
+
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
7
|
+
|
|
2
8
|
|
|
3
9
|
require "socket"
|
|
4
10
|
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
|
+
# Copyright (c) 2026 Code Infinity
|
|
3
|
+
# SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
# This Source Code Form is subject to the terms of the Mozilla Public
|
|
5
|
+
# License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
6
|
+
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
7
|
+
|
|
2
8
|
require "json"
|
|
3
9
|
require_relative "resp_client"
|
|
4
10
|
|
data/lib/tina4/shutdown.rb
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
|
+
# Copyright (c) 2026 Code Infinity
|
|
3
|
+
# SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
# This Source Code Form is subject to the terms of the Mozilla Public
|
|
5
|
+
# License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
6
|
+
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
7
|
+
|
|
2
8
|
|
|
3
9
|
module Tina4
|
|
4
10
|
# Graceful shutdown on SIGTERM / SIGINT.
|
|
@@ -80,10 +86,9 @@ module Tina4
|
|
|
80
86
|
@shutdown_complete = true
|
|
81
87
|
return if drained
|
|
82
88
|
|
|
83
|
-
# The deadline expired with requests still in flight.
|
|
84
|
-
#
|
|
85
|
-
#
|
|
86
|
-
# handler runs - exactly what TINA4_SHUTDOWN_TIMEOUT exists to prevent.
|
|
89
|
+
# The deadline expired with requests still in flight. Returning here
|
|
90
|
+
# would let the process live for as long as the slowest handler runs -
|
|
91
|
+
# exactly what TINA4_SHUTDOWN_TIMEOUT exists to prevent.
|
|
87
92
|
# Flush first: exit! runs no at_exit handlers and does not flush stdio,
|
|
88
93
|
# which would swallow the warning that explains the forced exit.
|
|
89
94
|
$stdout.flush
|
|
@@ -107,10 +112,9 @@ module Tina4
|
|
|
107
112
|
|
|
108
113
|
# Block until initiate_shutdown has finished every teardown step.
|
|
109
114
|
#
|
|
110
|
-
# stop_accepting unblocks
|
|
111
|
-
#
|
|
112
|
-
#
|
|
113
|
-
# closing database connections. The server's main thread calls this so the
|
|
115
|
+
# stop_accepting unblocks the built-in server's accept loop immediately, so
|
|
116
|
+
# its #start returns while the signal handler's thread is still draining
|
|
117
|
+
# requests, stopping background tasks and closing database connections. The server's main thread calls this so the
|
|
114
118
|
# process does not exit out from under that teardown.
|
|
115
119
|
def wait_for_completion(timeout = nil)
|
|
116
120
|
return unless @shutting_down
|
|
@@ -144,13 +148,12 @@ module Tina4
|
|
|
144
148
|
DEFAULT_TIMEOUT
|
|
145
149
|
end
|
|
146
150
|
|
|
147
|
-
# Close the listening socket BEFORE draining.
|
|
148
|
-
#
|
|
149
|
-
#
|
|
151
|
+
# Close the listening socket BEFORE draining. Tina4::HttpServer#shutdown
|
|
152
|
+
# closes every listener and every keep-alive connection that is waiting
|
|
153
|
+
# for its next request; it does not touch a request being served - so new
|
|
150
154
|
# connections are refused while in-flight requests still run to completion
|
|
151
|
-
# and write their full response
|
|
152
|
-
#
|
|
153
|
-
# ECONNREFUSED, and the 2s handler mid-flight still returns a full 200.
|
|
155
|
+
# and write their full response (spec/graceful_shutdown_spec.rb proves all
|
|
156
|
+
# three on a real SIGTERM).
|
|
154
157
|
def stop_accepting
|
|
155
158
|
return unless @server.respond_to?(:shutdown)
|
|
156
159
|
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# Copyright (c) 2026 Code Infinity
|
|
3
|
+
# SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
# This Source Code Form is subject to the terms of the Mozilla Public
|
|
5
|
+
# License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
6
|
+
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
require "socket"
|
|
10
|
+
require_relative "mail_socket"
|
|
11
|
+
require_relative "base64"
|
|
12
|
+
|
|
13
|
+
module Tina4
|
|
14
|
+
class Messenger
|
|
15
|
+
# Tina4's own SMTP client (RFC 5321), replacing the net-smtp gem. It covers
|
|
16
|
+
# exactly what Messenger sends with:
|
|
17
|
+
#
|
|
18
|
+
# * transport: plain, STARTTLS, or implicit TLS. Implicit TLS defaults to
|
|
19
|
+
# port 465, the rule Python (SMTP_SSL), PHP (ssl://) and Node (tls.connect)
|
|
20
|
+
# already follow; the caller may also ask for it on any port (tls: true).
|
|
21
|
+
# STARTTLS is REQUIRED when asked for: a server that does not offer it
|
|
22
|
+
# fails the send rather than silently getting the message and the
|
|
23
|
+
# credentials in clear (Net::SMTP#enable_starttls did the same).
|
|
24
|
+
# * AUTH PLAIN, or AUTH LOGIN when that is all the server offers.
|
|
25
|
+
# * MAIL FROM / RCPT TO / DATA with CRLF normalisation and dot-stuffing.
|
|
26
|
+
#
|
|
27
|
+
# SmtpClient.start(host: "smtp.example.com", port: 587, starttls: true,
|
|
28
|
+
# username: "u", password: "p") do |smtp|
|
|
29
|
+
# smtp.send_message(raw_message, "from@example.com", ["to@example.com"])
|
|
30
|
+
# end
|
|
31
|
+
class SmtpClient
|
|
32
|
+
# A server reply outside the expected codes; the message is the reply text.
|
|
33
|
+
class Error < StandardError
|
|
34
|
+
attr_reader :code
|
|
35
|
+
|
|
36
|
+
def initialize(code, message)
|
|
37
|
+
@code = code
|
|
38
|
+
super(message)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
IMPLICIT_TLS_PORT = 465
|
|
43
|
+
|
|
44
|
+
def self.start(**options)
|
|
45
|
+
client = new(**options)
|
|
46
|
+
client.connect
|
|
47
|
+
return client unless block_given?
|
|
48
|
+
|
|
49
|
+
begin
|
|
50
|
+
yield client
|
|
51
|
+
ensure
|
|
52
|
+
client.quit
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# auth_mechanism forces "PLAIN" or "LOGIN"; nil picks from the server's
|
|
57
|
+
# EHLO list (PLAIN preferred, as Net::SMTP's :plain always sent).
|
|
58
|
+
def initialize(host:, port:, tls: nil, starttls: false, username: nil, password: nil,
|
|
59
|
+
auth_mechanism: nil, timeout: 30, helo_domain: nil)
|
|
60
|
+
@host = host
|
|
61
|
+
@port = port.to_i
|
|
62
|
+
@implicit_tls = tls.nil? ? @port == IMPLICIT_TLS_PORT : tls
|
|
63
|
+
@starttls = starttls
|
|
64
|
+
@username = username
|
|
65
|
+
@password = password
|
|
66
|
+
@auth_mechanism = auth_mechanism&.upcase
|
|
67
|
+
@timeout = timeout
|
|
68
|
+
@helo_domain = helo_domain || local_host_name
|
|
69
|
+
@capabilities = []
|
|
70
|
+
@socket = nil
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
attr_reader :capabilities
|
|
74
|
+
|
|
75
|
+
def implicit_tls?
|
|
76
|
+
@implicit_tls
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def tls?
|
|
80
|
+
@socket&.tls? || false
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def connect
|
|
84
|
+
@socket = MailSocket.open(@host, @port, tls: implicit_tls?, timeout: @timeout)
|
|
85
|
+
read_reply(220)
|
|
86
|
+
greet
|
|
87
|
+
upgrade_to_tls if @starttls && !implicit_tls?
|
|
88
|
+
authenticate if @username && @password
|
|
89
|
+
self
|
|
90
|
+
rescue StandardError
|
|
91
|
+
close
|
|
92
|
+
raise
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def send_message(message, from, recipients)
|
|
96
|
+
recipients = Array(recipients).map { |recipient| envelope_address(recipient) }
|
|
97
|
+
raise ArgumentError, "at least one recipient is required" if recipients.empty?
|
|
98
|
+
|
|
99
|
+
command("MAIL FROM:<#{envelope_address(from)}>", 250)
|
|
100
|
+
recipients.each { |recipient| command("RCPT TO:<#{recipient}>", 250, 251) }
|
|
101
|
+
command("DATA", 354)
|
|
102
|
+
@socket.write(self.class.data_block(message))
|
|
103
|
+
read_reply(250)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# QUIT politely, then close. Never raises: the message is already queued.
|
|
107
|
+
def quit
|
|
108
|
+
return unless @socket
|
|
109
|
+
|
|
110
|
+
begin
|
|
111
|
+
command("QUIT", 221)
|
|
112
|
+
rescue StandardError
|
|
113
|
+
nil
|
|
114
|
+
end
|
|
115
|
+
close
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def close
|
|
119
|
+
@socket&.close
|
|
120
|
+
@socket = nil
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# The DATA payload: every line ending normalised to CRLF, a line starting
|
|
124
|
+
# with "." doubled (RFC 5321 4.5.2), terminated by CRLF.CRLF.
|
|
125
|
+
def self.data_block(message)
|
|
126
|
+
data = message.to_s.b.gsub(/\r\n|\r|\n/n, "\r\n")
|
|
127
|
+
data << "\r\n" unless data.end_with?("\r\n")
|
|
128
|
+
data.gsub(/^\./n, "..") + ".\r\n"
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
private
|
|
132
|
+
|
|
133
|
+
def greet
|
|
134
|
+
@capabilities = command("EHLO #{@helo_domain}", 250).drop(1)
|
|
135
|
+
rescue Error => e
|
|
136
|
+
raise unless (500..599).cover?(e.code)
|
|
137
|
+
|
|
138
|
+
# A server that does not speak ESMTP: plain HELO, no extensions.
|
|
139
|
+
command("HELO #{@helo_domain}", 250)
|
|
140
|
+
@capabilities = []
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def upgrade_to_tls
|
|
144
|
+
unless capability?("STARTTLS")
|
|
145
|
+
raise Error.new(0, "STARTTLS was requested but #{@host}:#{@port} does not offer it")
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
command("STARTTLS", 220)
|
|
149
|
+
@socket.start_tls
|
|
150
|
+
greet # capabilities change once the channel is encrypted (AUTH often appears)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def authenticate
|
|
154
|
+
case auth_mechanism
|
|
155
|
+
when "PLAIN"
|
|
156
|
+
command("AUTH PLAIN #{Tina4::Base64.strict_encode64("\0#{@username}\0#{@password}")}", 235)
|
|
157
|
+
when "LOGIN"
|
|
158
|
+
command("AUTH LOGIN", 334)
|
|
159
|
+
command(Tina4::Base64.strict_encode64(@username.to_s), 334)
|
|
160
|
+
command(Tina4::Base64.strict_encode64(@password.to_s), 235)
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def auth_mechanism
|
|
165
|
+
return @auth_mechanism if @auth_mechanism
|
|
166
|
+
|
|
167
|
+
offered = @capabilities.filter_map { |line| line[/\AAUTH[ =](.*)\z/i, 1] }
|
|
168
|
+
.flat_map { |list| list.upcase.split }
|
|
169
|
+
return "PLAIN" if offered.empty? || offered.include?("PLAIN")
|
|
170
|
+
return "LOGIN" if offered.include?("LOGIN")
|
|
171
|
+
|
|
172
|
+
raise Error.new(0, "#{@host}:#{@port} offers no supported AUTH mechanism (offered: #{offered.join(' ')})")
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def capability?(name)
|
|
176
|
+
@capabilities.any? { |line| line.split(/[ =]/, 2).first.casecmp?(name) }
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Send one command line and read its reply; returns the reply lines.
|
|
180
|
+
def command(line, *expected)
|
|
181
|
+
if line.match?(/[\r\n]/)
|
|
182
|
+
raise ArgumentError, "SMTP command contains a line break"
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
@socket.write("#{line}\r\n")
|
|
186
|
+
read_reply(*expected)
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# A reply is one or more "NNN-text" lines ending with "NNN text".
|
|
190
|
+
def read_reply(*expected)
|
|
191
|
+
lines = []
|
|
192
|
+
code = nil
|
|
193
|
+
loop do
|
|
194
|
+
raw = @socket.read_line.force_encoding(Encoding::UTF_8).scrub.chomp
|
|
195
|
+
code = raw[0, 3].to_i
|
|
196
|
+
lines << raw[4..].to_s
|
|
197
|
+
break unless raw[3] == "-"
|
|
198
|
+
end
|
|
199
|
+
return lines if expected.empty? || expected.include?(code)
|
|
200
|
+
|
|
201
|
+
raise Error.new(code, "#{code} #{lines.join(' ')}".strip)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# The bare address for the envelope: "Name <a@b>" -> "a@b". CR, LF and
|
|
205
|
+
# angle brackets inside it would let a caller inject SMTP commands.
|
|
206
|
+
def envelope_address(address)
|
|
207
|
+
text = address.to_s.strip
|
|
208
|
+
text = Regexp.last_match(1).strip if text =~ /<([^<>]*)>\s*\z/
|
|
209
|
+
raise ArgumentError, "invalid email address #{address.inspect}" if text.match?(/[\r\n<>]/)
|
|
210
|
+
|
|
211
|
+
text
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def local_host_name
|
|
215
|
+
name = Socket.gethostname.to_s
|
|
216
|
+
name.match?(/\A[A-Za-z0-9.\-]+\z/) ? name : "localhost"
|
|
217
|
+
rescue StandardError
|
|
218
|
+
"localhost"
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
end
|