tina4ruby 3.12.2 → 3.12.4
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/lib/tina4/ai.rb +8 -1
- data/lib/tina4/container.rb +1 -1
- data/lib/tina4/database.rb +10 -4
- data/lib/tina4/env.rb +7 -1
- data/lib/tina4/frond.rb +13 -3
- data/lib/tina4/graphql.rb +14 -1
- data/lib/tina4/health.rb +12 -1
- data/lib/tina4/log.rb +83 -42
- data/lib/tina4/mcp.rb +24 -0
- data/lib/tina4/messenger.rb +11 -4
- data/lib/tina4/response_cache.rb +172 -94
- data/lib/tina4/router.rb +8 -0
- data/lib/tina4/session.rb +16 -1
- data/lib/tina4/swagger.rb +33 -5
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4/webserver.rb +9 -3
- data/lib/tina4/websocket.rb +8 -4
- data/lib/tina4.rb +3 -0
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: be4da6079e890067fdf914b4dc287a01f68eeee914a51cd97f13e1d768d3d334
|
|
4
|
+
data.tar.gz: 61df8b97ae56df7aba1d40feee95d5f0208b969c5166cce97d8e6b65289ed720
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c2b2f07c1a30c66c114b7c77845751d6b8125125392cf8adf49689b23378c73d61b79a8882013eeccc6b28ce86dd15121437ec51e228690345c08f32d10572cf
|
|
7
|
+
data.tar.gz: b63ed9e9ffedece9880e1e57c43e355ec6b8ed4c5418914557ebb163e020728c5b8c0b4cfa41c08f86aae70160b2862eebbc52f128a0116cb6bc700bf4b63105
|
data/lib/tina4/ai.rb
CHANGED
|
@@ -212,11 +212,18 @@ module Tina4
|
|
|
212
212
|
next unless system("which #{cmd} > /dev/null 2>&1")
|
|
213
213
|
|
|
214
214
|
result = `#{cmd} install --upgrade tina4-ai 2>&1`
|
|
215
|
+
# Subprocess output is ASCII-8BIT — force UTF-8 with byte replacement
|
|
216
|
+
# so non-ASCII content (often emitted by pip on locale mismatch)
|
|
217
|
+
# doesn't crash String#strip with Encoding::CompatibilityError.
|
|
218
|
+
safe_result = result.dup.force_encoding("UTF-8")
|
|
219
|
+
unless safe_result.valid_encoding?
|
|
220
|
+
safe_result = safe_result.encode("UTF-8", "UTF-8", invalid: :replace, undef: :replace, replace: "?")
|
|
221
|
+
end
|
|
215
222
|
if $?.success?
|
|
216
223
|
puts " \e[32m✓\e[0m Installed tina4-ai (mdview)"
|
|
217
224
|
return
|
|
218
225
|
else
|
|
219
|
-
puts " \e[33m!\e[0m #{cmd} failed: #{
|
|
226
|
+
puts " \e[33m!\e[0m #{cmd} failed: #{safe_result.strip[0..100]}"
|
|
220
227
|
end
|
|
221
228
|
end
|
|
222
229
|
puts " \e[33m!\e[0m Python/pip not available -- skip tina4-ai"
|
data/lib/tina4/container.rb
CHANGED
data/lib/tina4/database.rb
CHANGED
|
@@ -83,7 +83,7 @@ module Tina4
|
|
|
83
83
|
}.freeze
|
|
84
84
|
|
|
85
85
|
# Static factory — cross-framework consistency: Database.create(url)
|
|
86
|
-
def self.create(url, username: "", password: "", pool:
|
|
86
|
+
def self.create(url, username: "", password: "", pool: nil)
|
|
87
87
|
new(url, username: username.empty? ? nil : username,
|
|
88
88
|
password: password.empty? ? nil : password,
|
|
89
89
|
pool: pool)
|
|
@@ -91,7 +91,7 @@ module Tina4
|
|
|
91
91
|
|
|
92
92
|
# Construct a Database from environment variables.
|
|
93
93
|
# Returns nil if the named env var is not set.
|
|
94
|
-
def self.from_env(env_key: "TINA4_DATABASE_URL", pool:
|
|
94
|
+
def self.from_env(env_key: "TINA4_DATABASE_URL", pool: nil)
|
|
95
95
|
url = ENV[env_key]
|
|
96
96
|
return nil if url.nil? || url.strip.empty?
|
|
97
97
|
|
|
@@ -101,12 +101,18 @@ module Tina4
|
|
|
101
101
|
pool: pool)
|
|
102
102
|
end
|
|
103
103
|
|
|
104
|
-
def initialize(connection_string = nil, username: nil, password: nil, driver_name: nil, pool:
|
|
104
|
+
def initialize(connection_string = nil, username: nil, password: nil, driver_name: nil, pool: nil)
|
|
105
105
|
@connection_string = connection_string || ENV["TINA4_DATABASE_URL"]
|
|
106
106
|
@username = username || ENV["TINA4_DATABASE_USERNAME"]
|
|
107
107
|
@password = password || ENV["TINA4_DATABASE_PASSWORD"]
|
|
108
108
|
@driver_name = driver_name || detect_driver(@connection_string)
|
|
109
|
-
|
|
109
|
+
# TINA4_DB_POOL falls back when caller doesn't pass `pool:` explicitly.
|
|
110
|
+
# Default 0 = single connection, N>0 = N pooled connections (round-robin).
|
|
111
|
+
@pool_size = if pool.nil?
|
|
112
|
+
(ENV["TINA4_DB_POOL"] || "0").to_i
|
|
113
|
+
else
|
|
114
|
+
pool
|
|
115
|
+
end
|
|
110
116
|
@connected = false
|
|
111
117
|
|
|
112
118
|
# Per-instance thread-local key for the transaction adapter pin.
|
data/lib/tina4/env.rb
CHANGED
|
@@ -62,7 +62,7 @@ module Tina4
|
|
|
62
62
|
end
|
|
63
63
|
lines.concat([
|
|
64
64
|
"",
|
|
65
|
-
"Run `tina4 env
|
|
65
|
+
"Run `tina4 env --migrate` to rewrite your .env automatically,",
|
|
66
66
|
"or rename manually. See https://tina4.com/release/3.12.0",
|
|
67
67
|
"Set TINA4_ALLOW_LEGACY_ENV=true to bypass during migration.",
|
|
68
68
|
sep, ""
|
|
@@ -132,6 +132,12 @@ module Tina4
|
|
|
132
132
|
private
|
|
133
133
|
|
|
134
134
|
def resolve_env_file(root_dir)
|
|
135
|
+
# TINA4_ENV_FILE wins — explicit path or filename (resolved against root_dir).
|
|
136
|
+
explicit = ENV["TINA4_ENV_FILE"]
|
|
137
|
+
if explicit && !explicit.empty?
|
|
138
|
+
return File.absolute_path?(explicit) ? explicit : File.join(root_dir, explicit)
|
|
139
|
+
end
|
|
140
|
+
|
|
135
141
|
environment = ENV["ENVIRONMENT"]
|
|
136
142
|
if environment && !environment.empty?
|
|
137
143
|
candidate = File.join(root_dir, ".env.#{environment}")
|
data/lib/tina4/frond.rb
CHANGED
|
@@ -190,6 +190,11 @@ module Tina4
|
|
|
190
190
|
end
|
|
191
191
|
|
|
192
192
|
# Render a template file with data. Uses token caching for performance.
|
|
193
|
+
#
|
|
194
|
+
# Caching strategy:
|
|
195
|
+
# * TINA4_DEBUG=true — never cache (always re-read + re-tokenize).
|
|
196
|
+
# * TINA4_TEMPLATE_CACHE_TTL > 0 — cache entries expire after N seconds.
|
|
197
|
+
# * TINA4_TEMPLATE_CACHE_TTL == 0 (default in production) — permanent cache.
|
|
193
198
|
def render(template, data = {})
|
|
194
199
|
context = @globals.merge(stringify_keys(data))
|
|
195
200
|
|
|
@@ -197,11 +202,16 @@ module Tina4
|
|
|
197
202
|
raise "Template not found: #{path}" unless File.exist?(path)
|
|
198
203
|
|
|
199
204
|
debug_mode = ENV.fetch("TINA4_DEBUG", "").downcase == "true"
|
|
205
|
+
ttl = (ENV["TINA4_TEMPLATE_CACHE_TTL"] || "0").to_i
|
|
200
206
|
|
|
201
207
|
unless debug_mode
|
|
202
|
-
# Production: use permanent cache (no filesystem checks)
|
|
203
208
|
cached = @compiled[template]
|
|
204
|
-
|
|
209
|
+
if cached
|
|
210
|
+
# cached layout: [tokens, mtime, cached_at]
|
|
211
|
+
tokens, _mtime, cached_at = cached
|
|
212
|
+
fresh = ttl <= 0 || (Time.now.to_i - cached_at.to_i) < ttl
|
|
213
|
+
return execute_cached(tokens, context) if fresh
|
|
214
|
+
end
|
|
205
215
|
end
|
|
206
216
|
# Dev mode: skip cache entirely — always re-read and re-tokenize
|
|
207
217
|
# so edits to partials and extended base templates are detected
|
|
@@ -210,7 +220,7 @@ module Tina4
|
|
|
210
220
|
source = File.read(path, encoding: "utf-8")
|
|
211
221
|
mtime = File.mtime(path)
|
|
212
222
|
tokens = tokenize(source)
|
|
213
|
-
@compiled[template] = [tokens, mtime]
|
|
223
|
+
@compiled[template] = [tokens, mtime, Time.now.to_i]
|
|
214
224
|
execute_with_tokens(source, tokens, context)
|
|
215
225
|
end
|
|
216
226
|
|
data/lib/tina4/graphql.rb
CHANGED
|
@@ -845,6 +845,14 @@ module Tina4
|
|
|
845
845
|
class GraphQL
|
|
846
846
|
attr_reader :schema
|
|
847
847
|
|
|
848
|
+
# Class-level toggle for ORM auto-schema generation. Defaults to true,
|
|
849
|
+
# can be disabled via TINA4_GRAPHQL_AUTO_SCHEMA=false. Initializers and
|
|
850
|
+
# user app code can branch on this before calling `schema.from_orm(...)`.
|
|
851
|
+
def self.auto_schema_enabled?
|
|
852
|
+
val = ENV.fetch("TINA4_GRAPHQL_AUTO_SCHEMA", "true").to_s.strip.downcase
|
|
853
|
+
!%w[false 0 no off].include?(val)
|
|
854
|
+
end
|
|
855
|
+
|
|
848
856
|
def initialize(schema = nil)
|
|
849
857
|
@schema = schema || GraphQLSchema.new
|
|
850
858
|
@executor = GraphQLExecutor.new(@schema)
|
|
@@ -916,7 +924,12 @@ module Tina4
|
|
|
916
924
|
# gql.register_route # POST /graphql
|
|
917
925
|
# gql.register_route("/api/graphql") # custom path
|
|
918
926
|
#
|
|
919
|
-
def register_route(path =
|
|
927
|
+
def register_route(path = nil)
|
|
928
|
+
# TINA4_GRAPHQL_ENDPOINT — defaults to /graphql when caller doesn't override.
|
|
929
|
+
path ||= ENV.fetch("TINA4_GRAPHQL_ENDPOINT", "/graphql")
|
|
930
|
+
path = path.to_s
|
|
931
|
+
path = "/#{path}" unless path.start_with?("/")
|
|
932
|
+
|
|
920
933
|
graphql = self
|
|
921
934
|
Tina4.post path, auth: false do |request, response|
|
|
922
935
|
body = request.body
|
data/lib/tina4/health.rb
CHANGED
|
@@ -7,8 +7,19 @@ module Tina4
|
|
|
7
7
|
START_TIME = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
8
8
|
|
|
9
9
|
class << self
|
|
10
|
+
# Return the configured health endpoint path.
|
|
11
|
+
# TINA4_HEALTH_PATH overrides the default "/__health" — kept consistent across all 4 frameworks.
|
|
12
|
+
def path
|
|
13
|
+
configured = ENV["TINA4_HEALTH_PATH"]
|
|
14
|
+
return "/__health" if configured.nil? || configured.empty?
|
|
15
|
+
configured.start_with?("/") ? configured : "/#{configured}"
|
|
16
|
+
end
|
|
17
|
+
|
|
10
18
|
def register!
|
|
11
|
-
|
|
19
|
+
# Register at the configured path. The legacy "/health" path stays
|
|
20
|
+
# registered for backward-compat.
|
|
21
|
+
Tina4::Router.add("GET", path, method(:handle))
|
|
22
|
+
Tina4::Router.add("GET", "/health", method(:handle)) unless path == "/health"
|
|
12
23
|
end
|
|
13
24
|
|
|
14
25
|
def handle(_request, response)
|
data/lib/tina4/log.rb
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "fileutils"
|
|
4
4
|
require "json"
|
|
5
|
+
require "logger"
|
|
5
6
|
|
|
6
7
|
module Tina4
|
|
7
8
|
module Log
|
|
@@ -27,23 +28,74 @@ module Tina4
|
|
|
27
28
|
# ANSI escape code regex for stripping from file output
|
|
28
29
|
ANSI_RE = /\033\[[0-9;]*m/
|
|
29
30
|
|
|
31
|
+
# Defaults used when env vars are unset.
|
|
32
|
+
DEFAULT_ROTATE_SIZE = 10 * 1024 * 1024 # 10MB
|
|
33
|
+
DEFAULT_ROTATE_KEEP = 5
|
|
34
|
+
|
|
30
35
|
class << self
|
|
31
|
-
attr_reader :log_dir
|
|
36
|
+
attr_reader :log_dir, :log_file_path
|
|
32
37
|
|
|
33
38
|
def configure(root_dir = Dir.pwd)
|
|
34
|
-
|
|
39
|
+
# TINA4_LOG_DIR — relative or absolute. Default "logs".
|
|
40
|
+
log_dir_env = ENV["TINA4_LOG_DIR"]
|
|
41
|
+
log_dir_env = "logs" if log_dir_env.nil? || log_dir_env.empty?
|
|
42
|
+
@log_dir = if File.absolute_path?(log_dir_env)
|
|
43
|
+
log_dir_env
|
|
44
|
+
else
|
|
45
|
+
File.join(root_dir, log_dir_env)
|
|
46
|
+
end
|
|
35
47
|
FileUtils.mkdir_p(@log_dir)
|
|
36
48
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
@
|
|
41
|
-
|
|
49
|
+
# TINA4_LOG_FILE — explicit log file path (absolute or relative to log_dir).
|
|
50
|
+
# Default: <log_dir>/tina4.log.
|
|
51
|
+
log_file_env = ENV["TINA4_LOG_FILE"]
|
|
52
|
+
@log_file_path = if log_file_env && !log_file_env.empty?
|
|
53
|
+
File.absolute_path?(log_file_env) ? log_file_env : File.join(@log_dir, log_file_env)
|
|
54
|
+
else
|
|
55
|
+
File.join(@log_dir, "tina4.log")
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# TINA4_LOG_ROTATE_SIZE — bytes per file before rotation. 0 = no rotation.
|
|
59
|
+
@rotate_size = (ENV["TINA4_LOG_ROTATE_SIZE"] || DEFAULT_ROTATE_SIZE).to_i
|
|
60
|
+
# TINA4_LOG_ROTATE_KEEP — number of rotated backups to keep.
|
|
61
|
+
@rotate_keep = (ENV["TINA4_LOG_ROTATE_KEEP"] || DEFAULT_ROTATE_KEEP).to_i
|
|
62
|
+
|
|
63
|
+
# TINA4_LOG_FORMAT — "text" or "json". Defaults to "json" in production, else "text".
|
|
64
|
+
format_env = ENV["TINA4_LOG_FORMAT"]
|
|
65
|
+
@format = format_env && !format_env.empty? ? format_env.downcase : (production? ? "json" : "text")
|
|
66
|
+
@json_mode = @format == "json"
|
|
67
|
+
|
|
68
|
+
# TINA4_LOG_OUTPUT — "stdout", "file", or "both". Defaults to "both".
|
|
69
|
+
output_env = ENV["TINA4_LOG_OUTPUT"]
|
|
70
|
+
@output = output_env && !output_env.empty? ? output_env.downcase : "both"
|
|
71
|
+
unless %w[stdout file both].include?(@output)
|
|
72
|
+
@output = "both"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# TINA4_LOG_CRITICAL — when true, raise on log write failures instead of swallowing.
|
|
76
|
+
@critical = truthy?(ENV["TINA4_LOG_CRITICAL"])
|
|
42
77
|
|
|
43
78
|
@console_level = resolve_level
|
|
44
79
|
@request_id = nil
|
|
45
80
|
@current_context = {}
|
|
46
81
|
@mutex = Mutex.new
|
|
82
|
+
|
|
83
|
+
# Build the file logger via stdlib Logger which handles rotation natively.
|
|
84
|
+
# Logger.new(path, shift_age, shift_size):
|
|
85
|
+
# shift_age = number of files to keep
|
|
86
|
+
# shift_size = bytes before rotation
|
|
87
|
+
# When @rotate_size is 0, omit rotation args.
|
|
88
|
+
close_file_logger
|
|
89
|
+
if @output != "stdout"
|
|
90
|
+
@file_logger = if @rotate_size > 0
|
|
91
|
+
::Logger.new(@log_file_path, @rotate_keep, @rotate_size)
|
|
92
|
+
else
|
|
93
|
+
::Logger.new(@log_file_path)
|
|
94
|
+
end
|
|
95
|
+
# We do our own formatting — strip Logger's default formatter.
|
|
96
|
+
@file_logger.formatter = proc { |_sev, _t, _p, msg| msg.to_s.end_with?("\n") ? msg : "#{msg}\n" }
|
|
97
|
+
end
|
|
98
|
+
|
|
47
99
|
@initialized = true
|
|
48
100
|
end
|
|
49
101
|
|
|
@@ -79,8 +131,19 @@ module Tina4
|
|
|
79
131
|
log(:error, message, context)
|
|
80
132
|
end
|
|
81
133
|
|
|
134
|
+
# Test/teardown helper — closes the underlying Logger so the file
|
|
135
|
+
# handle is released (Windows / tmpdir cleanup).
|
|
136
|
+
def close_file_logger
|
|
137
|
+
@file_logger&.close rescue nil
|
|
138
|
+
@file_logger = nil
|
|
139
|
+
end
|
|
140
|
+
|
|
82
141
|
private
|
|
83
142
|
|
|
143
|
+
def truthy?(val)
|
|
144
|
+
%w[true 1 yes on].include?(val.to_s.strip.downcase)
|
|
145
|
+
end
|
|
146
|
+
|
|
84
147
|
def production?
|
|
85
148
|
env = ENV["TINA4_ENV"] || ENV["RACK_ENV"] || ENV["RUBY_ENV"] || "development"
|
|
86
149
|
env.downcase == "production"
|
|
@@ -92,9 +155,9 @@ module Tina4
|
|
|
92
155
|
|
|
93
156
|
formatted = format_line(level, message)
|
|
94
157
|
|
|
95
|
-
# Console output respects TINA4_LOG_LEVEL
|
|
158
|
+
# Console output respects TINA4_LOG_LEVEL and TINA4_LOG_OUTPUT
|
|
96
159
|
severity = SEVERITY_MAP[level] || 0
|
|
97
|
-
if severity >= @console_level
|
|
160
|
+
if severity >= @console_level && @output != "file"
|
|
98
161
|
if @json_mode
|
|
99
162
|
$stdout.puts json_line(level, message)
|
|
100
163
|
else
|
|
@@ -102,8 +165,11 @@ module Tina4
|
|
|
102
165
|
end
|
|
103
166
|
end
|
|
104
167
|
|
|
105
|
-
# File always
|
|
106
|
-
|
|
168
|
+
# File output — always full level (consumer parses themselves) — unless disabled.
|
|
169
|
+
if @output != "stdout" && @file_logger
|
|
170
|
+
payload = @json_mode ? json_line(level, message) : strip_ansi(formatted)
|
|
171
|
+
write_to_file(payload)
|
|
172
|
+
end
|
|
107
173
|
|
|
108
174
|
@current_context = {}
|
|
109
175
|
end
|
|
@@ -166,37 +232,12 @@ module Tina4
|
|
|
166
232
|
end
|
|
167
233
|
|
|
168
234
|
def write_to_file(line)
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
end
|
|
176
|
-
|
|
177
|
-
# Numbered rotation: tina4.log → tina4.log.1 → tina4.log.2 → ... → tina4.log.{keep}
|
|
178
|
-
def rotate_if_needed
|
|
179
|
-
return unless File.exist?(@log_file)
|
|
180
|
-
|
|
181
|
-
begin
|
|
182
|
-
return if File.size(@log_file) < @max_size
|
|
183
|
-
rescue SystemCallError
|
|
184
|
-
return
|
|
185
|
-
end
|
|
186
|
-
|
|
187
|
-
# Delete the oldest rotated file if it exists
|
|
188
|
-
oldest = "#{@log_file}.#{@keep}"
|
|
189
|
-
File.delete(oldest) if File.exist?(oldest)
|
|
190
|
-
|
|
191
|
-
# Shift existing rotated files: .{n} → .{n+1}
|
|
192
|
-
(@keep - 1).downto(1) do |n|
|
|
193
|
-
src = "#{@log_file}.#{n}"
|
|
194
|
-
dst = "#{@log_file}.#{n + 1}"
|
|
195
|
-
File.rename(src, dst) if File.exist?(src)
|
|
196
|
-
end
|
|
197
|
-
|
|
198
|
-
# Rename current log to .1
|
|
199
|
-
File.rename(@log_file, "#{@log_file}.1") rescue nil
|
|
235
|
+
return unless @file_logger
|
|
236
|
+
# Use << to bypass Logger's severity filtering — we already filtered above.
|
|
237
|
+
@file_logger << "#{line}\n"
|
|
238
|
+
rescue IOError, SystemCallError => e
|
|
239
|
+
raise if @critical
|
|
240
|
+
# Don't crash on log write failure
|
|
200
241
|
end
|
|
201
242
|
end
|
|
202
243
|
end
|
data/lib/tina4/mcp.rb
CHANGED
|
@@ -136,6 +136,30 @@ module Tina4
|
|
|
136
136
|
["localhost", "127.0.0.1", "0.0.0.0", "::1", ""].include?(host)
|
|
137
137
|
end
|
|
138
138
|
|
|
139
|
+
# Resolve whether the built-in MCP dev server should be active.
|
|
140
|
+
#
|
|
141
|
+
# Precedence:
|
|
142
|
+
# * TINA4_MCP set explicitly → use that (truthy/falsey).
|
|
143
|
+
# * Otherwise: enabled only when TINA4_DEBUG=true.
|
|
144
|
+
def self.mcp_enabled?
|
|
145
|
+
explicit = ENV["TINA4_MCP"]
|
|
146
|
+
if explicit && !explicit.empty?
|
|
147
|
+
return %w[true 1 yes on].include?(explicit.to_s.strip.downcase)
|
|
148
|
+
end
|
|
149
|
+
%w[true 1 yes on].include?(ENV.fetch("TINA4_DEBUG", "").to_s.strip.downcase)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Resolve the dedicated MCP port. Defaults to (server port + 2000) — keeps
|
|
153
|
+
# MCP tooling reachable on a stable, predictable channel separate from the
|
|
154
|
+
# main HTTP port and the AI test port (port + 1000).
|
|
155
|
+
def self.mcp_port
|
|
156
|
+
explicit = ENV["TINA4_MCP_PORT"]
|
|
157
|
+
return explicit.to_i if explicit && !explicit.empty? && explicit.to_i > 0
|
|
158
|
+
|
|
159
|
+
base_port = (ENV["TINA4_PORT"] || ENV["PORT"] || "7147").to_i
|
|
160
|
+
base_port + 2000
|
|
161
|
+
end
|
|
162
|
+
|
|
139
163
|
# ── McpServer ─────────────────────────────────────────────────────
|
|
140
164
|
class McpServer
|
|
141
165
|
attr_reader :path, :name, :version
|
data/lib/tina4/messenger.rb
CHANGED
|
@@ -36,13 +36,14 @@ module Tina4
|
|
|
36
36
|
#
|
|
37
37
|
class Messenger
|
|
38
38
|
attr_reader :host, :port, :username, :from_address, :from_name,
|
|
39
|
-
:imap_host, :imap_port, :use_tls, :encryption
|
|
39
|
+
:imap_host, :imap_port, :use_tls, :encryption,
|
|
40
|
+
:imap_encryption, :imap_use_tls
|
|
40
41
|
|
|
41
42
|
# Initialize with SMTP config.
|
|
42
43
|
# Priority: constructor params > ENV (TINA4_MAIL_*) > sensible defaults
|
|
43
44
|
def initialize(host: nil, port: nil, username: nil, password: nil,
|
|
44
45
|
from_address: nil, from_name: nil, encryption: nil, use_tls: nil,
|
|
45
|
-
imap_host: nil, imap_port: nil)
|
|
46
|
+
imap_host: nil, imap_port: nil, imap_encryption: nil)
|
|
46
47
|
@host = host || ENV["TINA4_MAIL_HOST"] || "localhost"
|
|
47
48
|
@port = (port || ENV["TINA4_MAIL_PORT"] || 587).to_i
|
|
48
49
|
@username = username || ENV["TINA4_MAIL_USERNAME"]
|
|
@@ -53,7 +54,7 @@ module Tina4
|
|
|
53
54
|
|
|
54
55
|
@from_name = from_name || ENV["TINA4_MAIL_FROM_NAME"] || ""
|
|
55
56
|
|
|
56
|
-
#
|
|
57
|
+
# SMTP encryption: constructor > .env > backward-compat use_tls > default "tls"
|
|
57
58
|
env_encryption = encryption || ENV["TINA4_MAIL_ENCRYPTION"]
|
|
58
59
|
if env_encryption
|
|
59
60
|
@encryption = env_encryption.downcase
|
|
@@ -66,6 +67,12 @@ module Tina4
|
|
|
66
67
|
|
|
67
68
|
@imap_host = imap_host || ENV["TINA4_MAIL_IMAP_HOST"] || @host
|
|
68
69
|
@imap_port = (imap_port || ENV["TINA4_MAIL_IMAP_PORT"] || 993).to_i
|
|
70
|
+
|
|
71
|
+
# IMAP encryption: dedicated env var TINA4_MAIL_IMAP_ENCRYPTION (tls/starttls/none).
|
|
72
|
+
# Defaults to "tls" — IMAPS over implicit TLS on port 993 is the safe industry norm.
|
|
73
|
+
env_imap_enc = imap_encryption || ENV["TINA4_MAIL_IMAP_ENCRYPTION"]
|
|
74
|
+
@imap_encryption = (env_imap_enc && !env_imap_enc.to_s.empty?) ? env_imap_enc.to_s.downcase : "tls"
|
|
75
|
+
@imap_use_tls = %w[tls starttls ssl].include?(@imap_encryption)
|
|
69
76
|
end
|
|
70
77
|
|
|
71
78
|
# Send email using Ruby's Net::SMTP
|
|
@@ -387,7 +394,7 @@ module Tina4
|
|
|
387
394
|
# ── IMAP helpers ─────────────────────────────────────────────────────
|
|
388
395
|
|
|
389
396
|
def imap_connect(&block)
|
|
390
|
-
imap = Net::IMAP.new(@imap_host, port: @imap_port, ssl: @
|
|
397
|
+
imap = Net::IMAP.new(@imap_host, port: @imap_port, ssl: @imap_use_tls)
|
|
391
398
|
imap.login(@username, @password)
|
|
392
399
|
result = block.call(imap)
|
|
393
400
|
imap.logout
|
data/lib/tina4/response_cache.rb
CHANGED
|
@@ -3,6 +3,17 @@
|
|
|
3
3
|
module Tina4
|
|
4
4
|
# Multi-backend response cache for GET requests.
|
|
5
5
|
#
|
|
6
|
+
# Public surface (parity with Python tina4_python.cache):
|
|
7
|
+
# - Tina4::ResponseCache — middleware class
|
|
8
|
+
# - Tina4.cache_stats — module function returning cache stats
|
|
9
|
+
# - Tina4.clear_cache — module function flushing all cached entries
|
|
10
|
+
# - Tina4.cache_get / cache_set / cache_delete — module-level KV API
|
|
11
|
+
#
|
|
12
|
+
# The internal lookup/store of GET responses is performed by the middleware
|
|
13
|
+
# hooks (before_cache, after_cache) and is NOT exposed publicly. Use the
|
|
14
|
+
# middleware by attaching ResponseCache to your route, not by calling
|
|
15
|
+
# the (private) internal_lookup / internal_store directly.
|
|
16
|
+
#
|
|
6
17
|
# Backends are selected via the TINA4_CACHE_BACKEND env var:
|
|
7
18
|
# memory — in-process LRU cache (default, zero deps)
|
|
8
19
|
# redis — Redis / Valkey (uses `redis` gem or raw RESP over TCP)
|
|
@@ -14,16 +25,6 @@ module Tina4
|
|
|
14
25
|
# TINA4_CACHE_TTL — default TTL in seconds (default: 0 = disabled)
|
|
15
26
|
# TINA4_CACHE_MAX_ENTRIES — maximum cache entries (default: 1000)
|
|
16
27
|
#
|
|
17
|
-
# Usage:
|
|
18
|
-
# cache = Tina4::ResponseCache.new(ttl: 60, max_entries: 1000)
|
|
19
|
-
# cache.cache_response("GET", "/api/users", 200, "application/json", '{"users":[]}')
|
|
20
|
-
# hit = cache.get("GET", "/api/users")
|
|
21
|
-
#
|
|
22
|
-
# # Direct API (same across all 4 languages)
|
|
23
|
-
# cache.cache_set("key", {"data" => "value"}, ttl: 120)
|
|
24
|
-
# value = cache.cache_get("key")
|
|
25
|
-
# cache.cache_delete("key")
|
|
26
|
-
#
|
|
27
28
|
class ResponseCache
|
|
28
29
|
CacheEntry = Struct.new(:body, :content_type, :status_code, :expires_at)
|
|
29
30
|
|
|
@@ -57,94 +58,75 @@ module Tina4
|
|
|
57
58
|
@ttl > 0
|
|
58
59
|
end
|
|
59
60
|
|
|
60
|
-
#
|
|
61
|
-
#
|
|
62
|
-
# @param method [String]
|
|
63
|
-
# @param url [String]
|
|
64
|
-
# @return [String]
|
|
65
|
-
def cache_key(method, url)
|
|
66
|
-
"#{method}:#{url}"
|
|
67
|
-
end
|
|
68
|
-
|
|
69
|
-
# Retrieve a cached response. Returns nil on miss or expired entry.
|
|
70
|
-
#
|
|
71
|
-
# @param method [String]
|
|
72
|
-
# @param url [String]
|
|
73
|
-
# @return [CacheEntry, nil]
|
|
74
|
-
def get(method, url)
|
|
75
|
-
return nil unless enabled?
|
|
76
|
-
return nil unless method == "GET"
|
|
61
|
+
# ── Middleware hooks ────────────────────────────────────────────
|
|
77
62
|
|
|
78
|
-
|
|
79
|
-
|
|
63
|
+
# Middleware hook — checks for a cached entry before the route handler runs.
|
|
64
|
+
# If a cached entry exists for this GET request, short-circuits by replacing
|
|
65
|
+
# the response. Otherwise tags the request so after_cache can capture the
|
|
66
|
+
# response.
|
|
67
|
+
def before_cache(request, response)
|
|
68
|
+
return [request, response] unless enabled?
|
|
80
69
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
return nil
|
|
84
|
-
end
|
|
70
|
+
method = (request.respond_to?(:method) ? request.method : "GET").to_s.upcase
|
|
71
|
+
return [request, response] unless method == "GET"
|
|
85
72
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
return
|
|
73
|
+
url = request.respond_to?(:url) ? request.url : (request.respond_to?(:path) ? request.path : "/")
|
|
74
|
+
hit = internal_lookup(method, url)
|
|
75
|
+
if hit
|
|
76
|
+
if response.respond_to?(:call)
|
|
77
|
+
new_response = response.call(hit.body, hit.status_code, hit.content_type)
|
|
78
|
+
return [request, new_response]
|
|
92
79
|
end
|
|
93
|
-
@mutex.synchronize { @hits += 1 }
|
|
94
|
-
entry
|
|
95
|
-
elsif entry.is_a?(Hash)
|
|
96
|
-
expires_at = entry["expires_at"] || entry[:expires_at] || 0
|
|
97
|
-
if Time.now.to_f > expires_at
|
|
98
|
-
backend_delete(key)
|
|
99
|
-
@mutex.synchronize { @misses += 1 }
|
|
100
|
-
return nil
|
|
101
|
-
end
|
|
102
|
-
@mutex.synchronize { @hits += 1 }
|
|
103
|
-
CacheEntry.new(
|
|
104
|
-
entry["body"] || entry[:body],
|
|
105
|
-
entry["content_type"] || entry[:content_type],
|
|
106
|
-
entry["status_code"] || entry[:status_code],
|
|
107
|
-
expires_at
|
|
108
|
-
)
|
|
109
80
|
end
|
|
110
|
-
end
|
|
111
|
-
|
|
112
|
-
# Store a response in the cache.
|
|
113
|
-
#
|
|
114
|
-
# @param method [String]
|
|
115
|
-
# @param url [String]
|
|
116
|
-
# @param status_code [Integer]
|
|
117
|
-
# @param content_type [String]
|
|
118
|
-
# @param body [String]
|
|
119
|
-
# @param ttl [Integer, nil] override default TTL
|
|
120
|
-
def cache_response(method, url, status_code, content_type, body, ttl: nil)
|
|
121
|
-
return unless enabled?
|
|
122
|
-
return unless method == "GET"
|
|
123
|
-
return unless @status_codes.include?(status_code)
|
|
124
|
-
|
|
125
|
-
effective_ttl = ttl || @ttl
|
|
126
|
-
key = cache_key(method, url)
|
|
127
|
-
expires_at = Time.now.to_f + effective_ttl
|
|
128
|
-
|
|
129
|
-
entry_data = {
|
|
130
|
-
"body" => body,
|
|
131
|
-
"content_type" => content_type,
|
|
132
|
-
"status_code" => status_code,
|
|
133
|
-
"expires_at" => expires_at
|
|
134
|
-
}
|
|
135
81
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
oldest_key = @store.keys.first
|
|
141
|
-
@store.delete(oldest_key)
|
|
142
|
-
end
|
|
143
|
-
@store[key] = CacheEntry.new(body, content_type, status_code, expires_at)
|
|
144
|
-
end
|
|
82
|
+
# Tag for after_cache
|
|
83
|
+
if request.respond_to?(:[]=)
|
|
84
|
+
request[:_cache_method] = method
|
|
85
|
+
request[:_cache_url] = url
|
|
145
86
|
else
|
|
146
|
-
|
|
87
|
+
request.instance_variable_set(:@_cache_method, method)
|
|
88
|
+
request.instance_variable_set(:@_cache_url, url)
|
|
147
89
|
end
|
|
90
|
+
[request, response]
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Middleware hook — captures the response body and stores it after the
|
|
94
|
+
# route handler runs.
|
|
95
|
+
def after_cache(request, response)
|
|
96
|
+
return [request, response] unless enabled?
|
|
97
|
+
|
|
98
|
+
method = if request.respond_to?(:[])
|
|
99
|
+
request[:_cache_method]
|
|
100
|
+
else
|
|
101
|
+
request.instance_variable_get(:@_cache_method)
|
|
102
|
+
end
|
|
103
|
+
url = if request.respond_to?(:[])
|
|
104
|
+
request[:_cache_url]
|
|
105
|
+
else
|
|
106
|
+
request.instance_variable_get(:@_cache_url)
|
|
107
|
+
end
|
|
108
|
+
return [request, response] if method.nil? || url.nil?
|
|
109
|
+
|
|
110
|
+
status = if response.respond_to?(:status_code)
|
|
111
|
+
response.status_code
|
|
112
|
+
elsif response.respond_to?(:status)
|
|
113
|
+
response.status
|
|
114
|
+
else
|
|
115
|
+
200
|
|
116
|
+
end
|
|
117
|
+
content_type = if response.respond_to?(:content_type)
|
|
118
|
+
response.content_type
|
|
119
|
+
else
|
|
120
|
+
"application/json"
|
|
121
|
+
end
|
|
122
|
+
body = if response.respond_to?(:body)
|
|
123
|
+
response.body.to_s
|
|
124
|
+
else
|
|
125
|
+
response.to_s
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
internal_store(method, url, status.to_i, content_type.to_s, body)
|
|
129
|
+
[request, response]
|
|
148
130
|
end
|
|
149
131
|
|
|
150
132
|
# ── Direct Cache API (same across all 4 languages) ──────────
|
|
@@ -297,8 +279,95 @@ module Tina4
|
|
|
297
279
|
@backend_name
|
|
298
280
|
end
|
|
299
281
|
|
|
282
|
+
# @internal Test seam — exercises the same path the middleware uses.
|
|
283
|
+
# Public for parity tests only; do not use in application code.
|
|
284
|
+
def _internal_lookup(method, url)
|
|
285
|
+
internal_lookup(method, url)
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
# @internal Test seam — exercises the same path the middleware uses.
|
|
289
|
+
# Public for parity tests only; do not use in application code.
|
|
290
|
+
def _internal_store(method, url, status_code, content_type, body, ttl: nil)
|
|
291
|
+
internal_store(method, url, status_code, content_type, body, ttl: ttl)
|
|
292
|
+
end
|
|
293
|
+
|
|
300
294
|
private
|
|
301
295
|
|
|
296
|
+
# Build a cache key from method and URL.
|
|
297
|
+
def cache_key(method, url)
|
|
298
|
+
"#{method}:#{url}"
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
# Internal: retrieve a cached response. Used by middleware hooks only.
|
|
302
|
+
def internal_lookup(method, url)
|
|
303
|
+
return nil unless enabled?
|
|
304
|
+
return nil unless method == "GET"
|
|
305
|
+
|
|
306
|
+
key = cache_key(method, url)
|
|
307
|
+
entry = backend_get(key)
|
|
308
|
+
|
|
309
|
+
if entry.nil?
|
|
310
|
+
@mutex.synchronize { @misses += 1 }
|
|
311
|
+
return nil
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
# For memory backend, entry is a CacheEntry; for others, reconstruct
|
|
315
|
+
if entry.is_a?(CacheEntry)
|
|
316
|
+
if Time.now.to_f > entry.expires_at
|
|
317
|
+
backend_delete(key)
|
|
318
|
+
@mutex.synchronize { @misses += 1 }
|
|
319
|
+
return nil
|
|
320
|
+
end
|
|
321
|
+
@mutex.synchronize { @hits += 1 }
|
|
322
|
+
entry
|
|
323
|
+
elsif entry.is_a?(Hash)
|
|
324
|
+
expires_at = entry["expires_at"] || entry[:expires_at] || 0
|
|
325
|
+
if Time.now.to_f > expires_at
|
|
326
|
+
backend_delete(key)
|
|
327
|
+
@mutex.synchronize { @misses += 1 }
|
|
328
|
+
return nil
|
|
329
|
+
end
|
|
330
|
+
@mutex.synchronize { @hits += 1 }
|
|
331
|
+
CacheEntry.new(
|
|
332
|
+
entry["body"] || entry[:body],
|
|
333
|
+
entry["content_type"] || entry[:content_type],
|
|
334
|
+
entry["status_code"] || entry[:status_code],
|
|
335
|
+
expires_at
|
|
336
|
+
)
|
|
337
|
+
end
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
# Internal: store a response in the cache. Used by middleware hooks only.
|
|
341
|
+
def internal_store(method, url, status_code, content_type, body, ttl: nil)
|
|
342
|
+
return unless enabled?
|
|
343
|
+
return unless method == "GET"
|
|
344
|
+
return unless @status_codes.include?(status_code)
|
|
345
|
+
|
|
346
|
+
effective_ttl = ttl || @ttl
|
|
347
|
+
key = cache_key(method, url)
|
|
348
|
+
expires_at = Time.now.to_f + effective_ttl
|
|
349
|
+
|
|
350
|
+
entry_data = {
|
|
351
|
+
"body" => body,
|
|
352
|
+
"content_type" => content_type,
|
|
353
|
+
"status_code" => status_code,
|
|
354
|
+
"expires_at" => expires_at
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
case @backend_name
|
|
358
|
+
when "memory"
|
|
359
|
+
@mutex.synchronize do
|
|
360
|
+
if @store.size >= @max_entries && !@store.key?(key)
|
|
361
|
+
oldest_key = @store.keys.first
|
|
362
|
+
@store.delete(oldest_key)
|
|
363
|
+
end
|
|
364
|
+
@store[key] = CacheEntry.new(body, content_type, status_code, expires_at)
|
|
365
|
+
end
|
|
366
|
+
else
|
|
367
|
+
backend_set(key, entry_data, effective_ttl)
|
|
368
|
+
end
|
|
369
|
+
end
|
|
370
|
+
|
|
302
371
|
# ── Backend initialization ─────────────────────────────────
|
|
303
372
|
|
|
304
373
|
def init_backend
|
|
@@ -519,15 +588,17 @@ module Tina4
|
|
|
519
588
|
end
|
|
520
589
|
end
|
|
521
590
|
|
|
522
|
-
# ── Module-level convenience (singleton)
|
|
591
|
+
# ── Module-level convenience (singleton, parity with Python) ───
|
|
523
592
|
|
|
524
593
|
@default_cache = nil
|
|
525
594
|
|
|
526
595
|
class << self
|
|
596
|
+
# Lazy module-level singleton for cache_stats / clear_cache.
|
|
527
597
|
def cache_instance
|
|
528
598
|
@default_cache ||= ResponseCache.new(ttl: ENV["TINA4_CACHE_TTL"] ? ENV["TINA4_CACHE_TTL"].to_i : 60)
|
|
529
599
|
end
|
|
530
600
|
|
|
601
|
+
# Module-level KV API (parity with Python tina4_python.cache).
|
|
531
602
|
def cache_get(key)
|
|
532
603
|
cache_instance.cache_get(key)
|
|
533
604
|
end
|
|
@@ -540,12 +611,19 @@ module Tina4
|
|
|
540
611
|
cache_instance.cache_delete(key)
|
|
541
612
|
end
|
|
542
613
|
|
|
543
|
-
|
|
614
|
+
# Module-level cache stats (parity with Python tina4_python.cache.cache_stats()).
|
|
615
|
+
def cache_stats
|
|
616
|
+
cache_instance.cache_stats
|
|
617
|
+
end
|
|
618
|
+
|
|
619
|
+
# Module-level cache clear (parity with Python tina4_python.cache.clear_cache()).
|
|
620
|
+
def clear_cache
|
|
544
621
|
cache_instance.clear_cache
|
|
545
622
|
end
|
|
546
623
|
|
|
547
|
-
|
|
548
|
-
|
|
624
|
+
# Backward-compat alias for cache_clear (deprecated — use clear_cache).
|
|
625
|
+
def cache_clear
|
|
626
|
+
cache_instance.clear_cache
|
|
549
627
|
end
|
|
550
628
|
end
|
|
551
629
|
end
|
data/lib/tina4/router.rb
CHANGED
|
@@ -328,6 +328,14 @@ module Tina4
|
|
|
328
328
|
nil
|
|
329
329
|
end
|
|
330
330
|
|
|
331
|
+
# When TINA4_TRAILING_SLASH_REDIRECT is truthy, the rack app uses this
|
|
332
|
+
# to detect whether the *original* (un-stripped) path differed from the
|
|
333
|
+
# canonical form so it can issue a 301 redirect. Default false — silent
|
|
334
|
+
# match keeps backward compatibility.
|
|
335
|
+
def trailing_slash_redirect?
|
|
336
|
+
%w[true 1 yes on].include?(ENV.fetch("TINA4_TRAILING_SLASH_REDIRECT", "").to_s.strip.downcase)
|
|
337
|
+
end
|
|
338
|
+
|
|
331
339
|
# Find a route matching method + path. Returns [route, params] or nil.
|
|
332
340
|
# match(method, path) — consistent with Python, PHP, and Node.
|
|
333
341
|
def match(method, path)
|
data/lib/tina4/session.rb
CHANGED
|
@@ -16,6 +16,11 @@ module Tina4
|
|
|
16
16
|
|
|
17
17
|
def initialize(env, options = {})
|
|
18
18
|
@options = DEFAULT_OPTIONS.merge(options)
|
|
19
|
+
# TINA4_SESSION_NAME — overrides cookie_name unless caller explicitly passed one.
|
|
20
|
+
env_name = ENV["TINA4_SESSION_NAME"]
|
|
21
|
+
if !options.key?(:cookie_name) && env_name && !env_name.empty?
|
|
22
|
+
@options[:cookie_name] = env_name
|
|
23
|
+
end
|
|
19
24
|
@options[:secret] ||= ENV["TINA4_SECRET"] || "tina4-default-secret"
|
|
20
25
|
@handler = create_handler
|
|
21
26
|
@id = extract_session_id(env) || SecureRandom.hex(32)
|
|
@@ -151,7 +156,17 @@ module Tina4
|
|
|
151
156
|
def cookie_header(cookie_name = nil)
|
|
152
157
|
name = cookie_name || @options[:cookie_name]
|
|
153
158
|
samesite = ENV["TINA4_SESSION_SAMESITE"] || "Lax"
|
|
154
|
-
|
|
159
|
+
# HttpOnly defaults to true (existing behaviour); flip off only when explicitly false.
|
|
160
|
+
httponly = !%w[false 0 no off].include?((ENV["TINA4_SESSION_HTTPONLY"] || "true").to_s.strip.downcase)
|
|
161
|
+
# Secure defaults to false; flip on with truthy env var.
|
|
162
|
+
secure = %w[true 1 yes on].include?((ENV["TINA4_SESSION_SECURE"] || "false").to_s.strip.downcase)
|
|
163
|
+
|
|
164
|
+
parts = ["#{name}=#{@id}", "Path=/"]
|
|
165
|
+
parts << "HttpOnly" if httponly
|
|
166
|
+
parts << "Secure" if secure
|
|
167
|
+
parts << "SameSite=#{samesite}"
|
|
168
|
+
parts << "Max-Age=#{@options[:max_age]}"
|
|
169
|
+
parts.join("; ")
|
|
155
170
|
end
|
|
156
171
|
|
|
157
172
|
private
|
data/lib/tina4/swagger.rb
CHANGED
|
@@ -13,16 +13,44 @@ module Tina4
|
|
|
13
13
|
spec
|
|
14
14
|
end
|
|
15
15
|
|
|
16
|
+
# TINA4_SWAGGER_ENABLED — defaults to TINA4_DEBUG. When false, callers
|
|
17
|
+
# can choose to skip mounting /swagger entirely in production.
|
|
18
|
+
def enabled?
|
|
19
|
+
explicit = ENV["TINA4_SWAGGER_ENABLED"]
|
|
20
|
+
if explicit && !explicit.empty?
|
|
21
|
+
return %w[true 1 yes on].include?(explicit.to_s.strip.downcase)
|
|
22
|
+
end
|
|
23
|
+
%w[true 1 yes on].include?(ENV.fetch("TINA4_DEBUG", "").to_s.strip.downcase)
|
|
24
|
+
end
|
|
25
|
+
|
|
16
26
|
private
|
|
17
27
|
|
|
18
28
|
def base_spec
|
|
29
|
+
info = {
|
|
30
|
+
"title" => ENV["TINA4_SWAGGER_TITLE"] || ENV["PROJECT_NAME"] || "Tina4 API",
|
|
31
|
+
"version" => ENV["TINA4_SWAGGER_VERSION"] || Tina4::VERSION,
|
|
32
|
+
"description" => ENV["TINA4_SWAGGER_DESCRIPTION"] || "Auto-generated API documentation"
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
# Optional contact block — only emitted when at least one field is set.
|
|
36
|
+
contact_email = ENV["TINA4_SWAGGER_CONTACT_EMAIL"]
|
|
37
|
+
contact_team = ENV["TINA4_SWAGGER_CONTACT_TEAM"] || ENV["SWAGGER_CONTACT_TEAM"]
|
|
38
|
+
contact_url = ENV["TINA4_SWAGGER_CONTACT_URL"] || ENV["SWAGGER_CONTACT_URL"]
|
|
39
|
+
contact = {}
|
|
40
|
+
contact["email"] = contact_email if contact_email && !contact_email.empty?
|
|
41
|
+
contact["name"] = contact_team if contact_team && !contact_team.empty?
|
|
42
|
+
contact["url"] = contact_url if contact_url && !contact_url.empty?
|
|
43
|
+
info["contact"] = contact unless contact.empty?
|
|
44
|
+
|
|
45
|
+
# Optional license block — TINA4_SWAGGER_LICENSE is the SPDX name (e.g. "MIT").
|
|
46
|
+
license_name = ENV["TINA4_SWAGGER_LICENSE"]
|
|
47
|
+
if license_name && !license_name.empty?
|
|
48
|
+
info["license"] = { "name" => license_name }
|
|
49
|
+
end
|
|
50
|
+
|
|
19
51
|
{
|
|
20
52
|
"openapi" => "3.0.3",
|
|
21
|
-
"info" =>
|
|
22
|
-
"title" => ENV["TINA4_SWAGGER_TITLE"] || ENV["PROJECT_NAME"] || "Tina4 API",
|
|
23
|
-
"version" => ENV["TINA4_SWAGGER_VERSION"] || Tina4::VERSION,
|
|
24
|
-
"description" => ENV["TINA4_SWAGGER_DESCRIPTION"] || "Auto-generated API documentation"
|
|
25
|
-
},
|
|
53
|
+
"info" => info,
|
|
26
54
|
"servers" => [
|
|
27
55
|
{ "url" => "/" }
|
|
28
56
|
],
|
data/lib/tina4/version.rb
CHANGED
data/lib/tina4/webserver.rb
CHANGED
|
@@ -2,10 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
module Tina4
|
|
4
4
|
class WebServer
|
|
5
|
-
|
|
5
|
+
DEFAULT_HOST = "0.0.0.0"
|
|
6
|
+
DEFAULT_PORT = 7147
|
|
7
|
+
|
|
8
|
+
# TINA4_HOST overrides the bind address when caller doesn't pass `host:`.
|
|
9
|
+
def initialize(app, host: nil, port: nil)
|
|
6
10
|
@app = app
|
|
7
|
-
|
|
8
|
-
@
|
|
11
|
+
env_host = ENV["TINA4_HOST"]
|
|
12
|
+
@host = host || (env_host && !env_host.empty? ? env_host : DEFAULT_HOST)
|
|
13
|
+
env_port = ENV["TINA4_PORT"] || ENV["PORT"]
|
|
14
|
+
@port = port || (env_port && !env_port.empty? ? env_port.to_i : DEFAULT_PORT)
|
|
9
15
|
end
|
|
10
16
|
|
|
11
17
|
# Kill whatever process is listening on *port*.
|
data/lib/tina4/websocket.rb
CHANGED
|
@@ -105,12 +105,16 @@ module Tina4
|
|
|
105
105
|
|
|
106
106
|
# ── Rooms ──────────────────────────────────────────────────
|
|
107
107
|
|
|
108
|
-
|
|
108
|
+
# Internal: add connection ID to a room (called by WebSocketConnection#join_room).
|
|
109
|
+
# Mirrors Python's WebSocketManager._join_room — not part of the public API.
|
|
110
|
+
def _join_room(conn_id, room_name)
|
|
109
111
|
@rooms[room_name] ||= Set.new
|
|
110
112
|
@rooms[room_name].add(conn_id)
|
|
111
113
|
end
|
|
112
114
|
|
|
113
|
-
|
|
115
|
+
# Internal: remove connection ID from a room (called by WebSocketConnection#leave_room).
|
|
116
|
+
# Mirrors Python's WebSocketManager._leave_room — not part of the public API.
|
|
117
|
+
def _leave_room(conn_id, room_name)
|
|
114
118
|
@rooms[room_name]&.delete(conn_id)
|
|
115
119
|
end
|
|
116
120
|
|
|
@@ -251,12 +255,12 @@ module Tina4
|
|
|
251
255
|
|
|
252
256
|
def join_room(room_name)
|
|
253
257
|
@rooms.add(room_name)
|
|
254
|
-
@ws_server&.
|
|
258
|
+
@ws_server&._join_room(@id, room_name)
|
|
255
259
|
end
|
|
256
260
|
|
|
257
261
|
def leave_room(room_name)
|
|
258
262
|
@rooms.delete(room_name)
|
|
259
|
-
@ws_server&.
|
|
263
|
+
@ws_server&._leave_room(@id, room_name)
|
|
260
264
|
end
|
|
261
265
|
|
|
262
266
|
def broadcast_to_room(room_name, message, exclude_self: false)
|
data/lib/tina4.rb
CHANGED
|
@@ -119,6 +119,9 @@ module Tina4
|
|
|
119
119
|
attr_accessor :root_dir, :database
|
|
120
120
|
|
|
121
121
|
def print_banner(host: "0.0.0.0", port: 7147, server_name: nil)
|
|
122
|
+
# TINA4_SUPPRESS — short-circuit ALL banner output for headless / CI runs.
|
|
123
|
+
return if Tina4::Env.is_truthy(ENV["TINA4_SUPPRESS"])
|
|
124
|
+
|
|
122
125
|
is_tty = $stdout.respond_to?(:isatty) && $stdout.isatty
|
|
123
126
|
color = is_tty ? "\e[31m" : ""
|
|
124
127
|
reset = is_tty ? "\e[0m" : ""
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: tina4ruby
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 3.12.
|
|
4
|
+
version: 3.12.4
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Tina4 Team
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-05-
|
|
11
|
+
date: 2026-05-06 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rack
|