tina4ruby 3.12.3 → 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/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/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.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/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/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.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
|