tina4ruby 3.2.1 → 3.9.2
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/README.md +19 -20
- data/lib/tina4/auth.rb +137 -27
- data/lib/tina4/auto_crud.rb +55 -3
- data/lib/tina4/cli.rb +75 -2
- data/lib/tina4/cors.rb +1 -1
- data/lib/tina4/database.rb +131 -28
- data/lib/tina4/database_result.rb +122 -8
- data/lib/tina4/env.rb +1 -1
- data/lib/tina4/frond.rb +148 -2
- data/lib/tina4/localization.rb +1 -1
- data/lib/tina4/middleware.rb +349 -1
- data/lib/tina4/migration.rb +132 -11
- data/lib/tina4/orm.rb +17 -8
- data/lib/tina4/public/js/tina4-dev-admin.min.js +1 -1
- data/lib/tina4/public/js/tina4js.min.js +47 -0
- data/lib/tina4/query_builder.rb +374 -0
- data/lib/tina4/queue.rb +128 -90
- data/lib/tina4/queue_backends/lite_backend.rb +42 -7
- data/lib/tina4/queue_backends/mongo_backend.rb +126 -0
- data/lib/tina4/rack_app.rb +194 -18
- data/lib/tina4/request.rb +14 -1
- data/lib/tina4/response.rb +26 -0
- data/lib/tina4/router.rb +127 -0
- data/lib/tina4/service_runner.rb +1 -1
- data/lib/tina4/session.rb +6 -1
- data/lib/tina4/session_handlers/database_handler.rb +66 -0
- data/lib/tina4/swagger.rb +1 -1
- data/lib/tina4/validator.rb +174 -0
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4/websocket.rb +23 -4
- data/lib/tina4/websocket_backplane.rb +118 -0
- data/lib/tina4.rb +64 -4
- metadata +12 -3
data/lib/tina4/database.rb
CHANGED
|
@@ -4,8 +4,69 @@ require "uri"
|
|
|
4
4
|
require "digest"
|
|
5
5
|
|
|
6
6
|
module Tina4
|
|
7
|
+
# Thread-safe connection pool with round-robin rotation.
|
|
8
|
+
# Connections are created lazily on first use.
|
|
9
|
+
class ConnectionPool
|
|
10
|
+
attr_reader :size
|
|
11
|
+
|
|
12
|
+
def initialize(pool_size, driver_factory:, connection_string:, username: nil, password: nil)
|
|
13
|
+
@pool_size = pool_size
|
|
14
|
+
@driver_factory = driver_factory
|
|
15
|
+
@connection_string = connection_string
|
|
16
|
+
@username = username
|
|
17
|
+
@password = password
|
|
18
|
+
@drivers = Array.new(pool_size) # nil slots — lazy creation
|
|
19
|
+
@index = 0
|
|
20
|
+
@mutex = Mutex.new
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Get the next driver via round-robin. Thread-safe.
|
|
24
|
+
def checkout
|
|
25
|
+
@mutex.synchronize do
|
|
26
|
+
idx = @index
|
|
27
|
+
@index = (@index + 1) % @pool_size
|
|
28
|
+
|
|
29
|
+
if @drivers[idx].nil?
|
|
30
|
+
driver = @driver_factory.call
|
|
31
|
+
driver.connect(@connection_string, username: @username, password: @password)
|
|
32
|
+
@drivers[idx] = driver
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
@drivers[idx]
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Return a driver to the pool. Currently a no-op for round-robin.
|
|
40
|
+
def checkin(_driver)
|
|
41
|
+
# no-op
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Close all active connections.
|
|
45
|
+
def close_all
|
|
46
|
+
@mutex.synchronize do
|
|
47
|
+
@drivers.each_with_index do |driver, i|
|
|
48
|
+
if driver
|
|
49
|
+
driver.close rescue nil
|
|
50
|
+
@drivers[i] = nil
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Number of connections that have been created.
|
|
57
|
+
def active_count
|
|
58
|
+
@mutex.synchronize do
|
|
59
|
+
@drivers.count { |d| !d.nil? }
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def size
|
|
64
|
+
@pool_size
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
7
68
|
class Database
|
|
8
|
-
attr_reader :driver, :driver_name, :connected
|
|
69
|
+
attr_reader :driver, :driver_name, :connected, :pool
|
|
9
70
|
|
|
10
71
|
DRIVERS = {
|
|
11
72
|
"sqlite" => "Tina4::Drivers::SqliteDriver",
|
|
@@ -18,12 +79,12 @@ module Tina4
|
|
|
18
79
|
"firebird" => "Tina4::Drivers::FirebirdDriver"
|
|
19
80
|
}.freeze
|
|
20
81
|
|
|
21
|
-
def initialize(connection_string = nil, username: nil, password: nil, driver_name: nil)
|
|
82
|
+
def initialize(connection_string = nil, username: nil, password: nil, driver_name: nil, pool: 0)
|
|
22
83
|
@connection_string = connection_string || ENV["DATABASE_URL"]
|
|
23
84
|
@username = username || ENV["DATABASE_USERNAME"]
|
|
24
85
|
@password = password || ENV["DATABASE_PASSWORD"]
|
|
25
86
|
@driver_name = driver_name || detect_driver(@connection_string)
|
|
26
|
-
@
|
|
87
|
+
@pool_size = pool # 0 = single connection, N>0 = N pooled connections
|
|
27
88
|
@connected = false
|
|
28
89
|
|
|
29
90
|
# Query cache — off by default, opt-in via TINA4_DB_CACHE=true
|
|
@@ -34,12 +95,34 @@ module Tina4
|
|
|
34
95
|
@cache_misses = 0
|
|
35
96
|
@cache_mutex = Mutex.new
|
|
36
97
|
|
|
37
|
-
|
|
98
|
+
if @pool_size > 0
|
|
99
|
+
# Pooled mode — create a ConnectionPool with lazy driver creation
|
|
100
|
+
@pool = ConnectionPool.new(
|
|
101
|
+
@pool_size,
|
|
102
|
+
driver_factory: method(:create_driver),
|
|
103
|
+
connection_string: @connection_string,
|
|
104
|
+
username: @username,
|
|
105
|
+
password: @password
|
|
106
|
+
)
|
|
107
|
+
@driver = nil
|
|
108
|
+
@connected = true
|
|
109
|
+
else
|
|
110
|
+
# Single-connection mode — current behavior
|
|
111
|
+
@pool = nil
|
|
112
|
+
@driver = create_driver
|
|
113
|
+
connect
|
|
114
|
+
end
|
|
38
115
|
end
|
|
39
116
|
|
|
40
117
|
def connect
|
|
41
118
|
@driver.connect(@connection_string, username: @username, password: @password)
|
|
42
119
|
@connected = true
|
|
120
|
+
|
|
121
|
+
# Enable autocommit if TINA4_AUTOCOMMIT env var is set
|
|
122
|
+
if truthy?(ENV["TINA4_AUTOCOMMIT"]) && @driver.respond_to?(:autocommit=)
|
|
123
|
+
@driver.autocommit = true
|
|
124
|
+
end
|
|
125
|
+
|
|
43
126
|
Tina4::Log.info("Database connected: #{@driver_name}")
|
|
44
127
|
rescue => e
|
|
45
128
|
Tina4::Log.error("Database connection failed: #{e.message}")
|
|
@@ -47,10 +130,23 @@ module Tina4
|
|
|
47
130
|
end
|
|
48
131
|
|
|
49
132
|
def close
|
|
50
|
-
|
|
133
|
+
if @pool
|
|
134
|
+
@pool.close_all
|
|
135
|
+
elsif @driver && @connected
|
|
136
|
+
@driver.close
|
|
137
|
+
end
|
|
51
138
|
@connected = false
|
|
52
139
|
end
|
|
53
140
|
|
|
141
|
+
# Get the current driver — from pool (round-robin) or single connection.
|
|
142
|
+
def current_driver
|
|
143
|
+
if @pool
|
|
144
|
+
@pool.checkout
|
|
145
|
+
else
|
|
146
|
+
@driver
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
54
150
|
# ── Query Cache ──────────────────────────────────────────────
|
|
55
151
|
|
|
56
152
|
def cache_stats
|
|
@@ -73,10 +169,13 @@ module Tina4
|
|
|
73
169
|
end
|
|
74
170
|
end
|
|
75
171
|
|
|
76
|
-
def fetch(sql, params = [], limit: nil,
|
|
172
|
+
def fetch(sql, params = [], limit: nil, offset: nil)
|
|
173
|
+
offset ||= 0
|
|
174
|
+
drv = current_driver
|
|
175
|
+
|
|
77
176
|
effective_sql = sql
|
|
78
177
|
if limit
|
|
79
|
-
effective_sql =
|
|
178
|
+
effective_sql = drv.apply_limit(effective_sql, limit, offset)
|
|
80
179
|
end
|
|
81
180
|
|
|
82
181
|
if @cache_enabled
|
|
@@ -86,15 +185,15 @@ module Tina4
|
|
|
86
185
|
@cache_mutex.synchronize { @cache_hits += 1 }
|
|
87
186
|
return cached
|
|
88
187
|
end
|
|
89
|
-
result =
|
|
90
|
-
result = Tina4::DatabaseResult.new(result, sql: effective_sql)
|
|
188
|
+
result = drv.execute_query(effective_sql, params)
|
|
189
|
+
result = Tina4::DatabaseResult.new(result, sql: effective_sql, db: self)
|
|
91
190
|
cache_set(key, result)
|
|
92
191
|
@cache_mutex.synchronize { @cache_misses += 1 }
|
|
93
192
|
return result
|
|
94
193
|
end
|
|
95
194
|
|
|
96
|
-
rows =
|
|
97
|
-
Tina4::DatabaseResult.new(rows, sql: effective_sql)
|
|
195
|
+
rows = drv.execute_query(effective_sql, params)
|
|
196
|
+
Tina4::DatabaseResult.new(rows, sql: effective_sql, db: self)
|
|
98
197
|
end
|
|
99
198
|
|
|
100
199
|
def fetch_one(sql, params = [])
|
|
@@ -118,38 +217,41 @@ module Tina4
|
|
|
118
217
|
|
|
119
218
|
def insert(table, data)
|
|
120
219
|
cache_invalidate if @cache_enabled
|
|
220
|
+
drv = current_driver
|
|
121
221
|
|
|
122
222
|
# List of hashes — batch insert
|
|
123
223
|
if data.is_a?(Array)
|
|
124
224
|
return { success: true, affected_rows: 0 } if data.empty?
|
|
125
225
|
keys = data.first.keys.map(&:to_s)
|
|
126
|
-
placeholders =
|
|
226
|
+
placeholders = drv.placeholders(keys.length)
|
|
127
227
|
sql = "INSERT INTO #{table} (#{keys.join(', ')}) VALUES (#{placeholders})"
|
|
128
228
|
params_list = data.map { |row| keys.map { |k| row[k.to_sym] || row[k] } }
|
|
129
229
|
return execute_many(sql, params_list)
|
|
130
230
|
end
|
|
131
231
|
|
|
132
232
|
columns = data.keys.map(&:to_s)
|
|
133
|
-
placeholders =
|
|
233
|
+
placeholders = drv.placeholders(columns.length)
|
|
134
234
|
sql = "INSERT INTO #{table} (#{columns.join(', ')}) VALUES (#{placeholders})"
|
|
135
|
-
|
|
136
|
-
{ success: true, last_id:
|
|
235
|
+
drv.execute(sql, data.values)
|
|
236
|
+
{ success: true, last_id: drv.last_insert_id }
|
|
137
237
|
end
|
|
138
238
|
|
|
139
239
|
def update(table, data, filter = {})
|
|
140
240
|
cache_invalidate if @cache_enabled
|
|
241
|
+
drv = current_driver
|
|
141
242
|
|
|
142
|
-
set_parts = data.keys.map { |k| "#{k} = #{
|
|
143
|
-
where_parts = filter.keys.map { |k| "#{k} = #{
|
|
243
|
+
set_parts = data.keys.map { |k| "#{k} = #{drv.placeholder}" }
|
|
244
|
+
where_parts = filter.keys.map { |k| "#{k} = #{drv.placeholder}" }
|
|
144
245
|
sql = "UPDATE #{table} SET #{set_parts.join(', ')}"
|
|
145
246
|
sql += " WHERE #{where_parts.join(' AND ')}" unless filter.empty?
|
|
146
247
|
values = data.values + filter.values
|
|
147
|
-
|
|
248
|
+
drv.execute(sql, values)
|
|
148
249
|
{ success: true }
|
|
149
250
|
end
|
|
150
251
|
|
|
151
252
|
def delete(table, filter = {})
|
|
152
253
|
cache_invalidate if @cache_enabled
|
|
254
|
+
drv = current_driver
|
|
153
255
|
|
|
154
256
|
# List of hashes — delete each row
|
|
155
257
|
if filter.is_a?(Array)
|
|
@@ -161,47 +263,48 @@ module Tina4
|
|
|
161
263
|
if filter.is_a?(String)
|
|
162
264
|
sql = "DELETE FROM #{table}"
|
|
163
265
|
sql += " WHERE #{filter}" unless filter.empty?
|
|
164
|
-
|
|
266
|
+
drv.execute(sql)
|
|
165
267
|
return { success: true }
|
|
166
268
|
end
|
|
167
269
|
|
|
168
270
|
# Hash filter — build WHERE from keys
|
|
169
|
-
where_parts = filter.keys.map { |k| "#{k} = #{
|
|
271
|
+
where_parts = filter.keys.map { |k| "#{k} = #{drv.placeholder}" }
|
|
170
272
|
sql = "DELETE FROM #{table}"
|
|
171
273
|
sql += " WHERE #{where_parts.join(' AND ')}" unless filter.empty?
|
|
172
|
-
|
|
274
|
+
drv.execute(sql, filter.values)
|
|
173
275
|
{ success: true }
|
|
174
276
|
end
|
|
175
277
|
|
|
176
278
|
def execute(sql, params = [])
|
|
177
279
|
cache_invalidate if @cache_enabled
|
|
178
|
-
|
|
280
|
+
current_driver.execute(sql, params)
|
|
179
281
|
end
|
|
180
282
|
|
|
181
283
|
def execute_many(sql, params_list = [])
|
|
182
284
|
total_affected = 0
|
|
183
285
|
params_list.each do |params|
|
|
184
|
-
|
|
286
|
+
current_driver.execute(sql, params)
|
|
185
287
|
total_affected += 1
|
|
186
288
|
end
|
|
187
289
|
{ success: true, affected_rows: total_affected }
|
|
188
290
|
end
|
|
189
291
|
|
|
190
292
|
def transaction
|
|
191
|
-
|
|
293
|
+
drv = current_driver
|
|
294
|
+
drv.begin_transaction
|
|
192
295
|
yield self
|
|
193
|
-
|
|
296
|
+
drv.commit
|
|
194
297
|
rescue => e
|
|
195
|
-
|
|
298
|
+
drv.rollback
|
|
196
299
|
raise e
|
|
197
300
|
end
|
|
198
301
|
|
|
199
302
|
def tables
|
|
200
|
-
|
|
303
|
+
current_driver.tables
|
|
201
304
|
end
|
|
202
305
|
|
|
203
306
|
def columns(table_name)
|
|
204
|
-
|
|
307
|
+
current_driver.columns(table_name)
|
|
205
308
|
end
|
|
206
309
|
|
|
207
310
|
def table_exists?(table_name)
|
|
@@ -5,12 +5,22 @@ module Tina4
|
|
|
5
5
|
class DatabaseResult
|
|
6
6
|
include Enumerable
|
|
7
7
|
|
|
8
|
-
attr_reader :records, :
|
|
8
|
+
attr_reader :records, :columns, :count, :limit, :offset, :sql,
|
|
9
|
+
:affected_rows, :last_id, :error
|
|
9
10
|
|
|
10
|
-
def initialize(records = [], sql: ""
|
|
11
|
+
def initialize(records = [], sql: "", columns: [], count: nil, limit: 10, offset: 0,
|
|
12
|
+
affected_rows: 0, last_id: nil, error: nil, db: nil)
|
|
11
13
|
@records = records || []
|
|
12
14
|
@sql = sql
|
|
13
|
-
@
|
|
15
|
+
@columns = columns.empty? && !@records.empty? ? @records.first.keys : columns
|
|
16
|
+
@count = count || @records.length
|
|
17
|
+
@limit = limit
|
|
18
|
+
@offset = offset
|
|
19
|
+
@affected_rows = affected_rows
|
|
20
|
+
@last_id = last_id
|
|
21
|
+
@error = error
|
|
22
|
+
@db = db
|
|
23
|
+
@column_info_cache = nil
|
|
14
24
|
end
|
|
15
25
|
|
|
16
26
|
def each(&block)
|
|
@@ -33,12 +43,26 @@ module Tina4
|
|
|
33
43
|
@records[index]
|
|
34
44
|
end
|
|
35
45
|
|
|
46
|
+
def length
|
|
47
|
+
@count
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def size
|
|
51
|
+
@count
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def success?
|
|
55
|
+
@error.nil?
|
|
56
|
+
end
|
|
57
|
+
|
|
36
58
|
def to_array
|
|
37
59
|
@records.map do |record|
|
|
38
60
|
record.is_a?(Hash) ? record : record.to_h
|
|
39
61
|
end
|
|
40
62
|
end
|
|
41
63
|
|
|
64
|
+
alias to_a to_array
|
|
65
|
+
|
|
42
66
|
def to_json(*_args)
|
|
43
67
|
JSON.generate(to_array)
|
|
44
68
|
end
|
|
@@ -54,11 +78,13 @@ module Tina4
|
|
|
54
78
|
lines.join("\n")
|
|
55
79
|
end
|
|
56
80
|
|
|
57
|
-
def to_paginate(page:
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
81
|
+
def to_paginate(page: nil, per_page: nil)
|
|
82
|
+
per_page ||= @limit > 0 ? @limit : 10
|
|
83
|
+
page ||= @offset > 0 ? (@offset / per_page) + 1 : 1
|
|
84
|
+
total = @count
|
|
85
|
+
total_pages = [1, (total.to_f / per_page).ceil].max
|
|
86
|
+
slice_offset = (page - 1) * per_page
|
|
87
|
+
page_records = @records[slice_offset, per_page] || []
|
|
62
88
|
{
|
|
63
89
|
data: page_records,
|
|
64
90
|
page: page,
|
|
@@ -75,8 +101,96 @@ module Tina4
|
|
|
75
101
|
primary_key: primary_key, editable: editable)
|
|
76
102
|
end
|
|
77
103
|
|
|
104
|
+
# Return column metadata for the query's table.
|
|
105
|
+
#
|
|
106
|
+
# Lazy — only queries the database when explicitly called. Caches the
|
|
107
|
+
# result so subsequent calls return immediately without re-querying.
|
|
108
|
+
#
|
|
109
|
+
# Returns an array of hashes with keys:
|
|
110
|
+
# name, type, size, decimals, nullable, primary_key
|
|
111
|
+
def column_info
|
|
112
|
+
return @column_info_cache if @column_info_cache
|
|
113
|
+
|
|
114
|
+
table = extract_table_from_sql
|
|
115
|
+
|
|
116
|
+
if @db && table
|
|
117
|
+
begin
|
|
118
|
+
@column_info_cache = query_column_metadata(table)
|
|
119
|
+
return @column_info_cache
|
|
120
|
+
rescue StandardError
|
|
121
|
+
# Fall through to fallback
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
@column_info_cache = fallback_column_info
|
|
126
|
+
@column_info_cache
|
|
127
|
+
end
|
|
128
|
+
|
|
78
129
|
private
|
|
79
130
|
|
|
131
|
+
def extract_table_from_sql
|
|
132
|
+
return nil if @sql.nil? || @sql.empty?
|
|
133
|
+
|
|
134
|
+
if (m = @sql.match(/\bFROM\s+["']?(\w+)["']?/i))
|
|
135
|
+
return m[1]
|
|
136
|
+
end
|
|
137
|
+
if (m = @sql.match(/\bINSERT\s+INTO\s+["']?(\w+)["']?/i))
|
|
138
|
+
return m[1]
|
|
139
|
+
end
|
|
140
|
+
if (m = @sql.match(/\bUPDATE\s+["']?(\w+)["']?/i))
|
|
141
|
+
return m[1]
|
|
142
|
+
end
|
|
143
|
+
nil
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def query_column_metadata(table)
|
|
147
|
+
# Use the database's columns method which delegates to the driver
|
|
148
|
+
raw_cols = @db.columns(table)
|
|
149
|
+
normalize_columns(raw_cols)
|
|
150
|
+
rescue StandardError
|
|
151
|
+
fallback_column_info
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def normalize_columns(raw_cols)
|
|
155
|
+
raw_cols.map do |col|
|
|
156
|
+
col_type = (col[:type] || col["type"] || "UNKNOWN").to_s.upcase
|
|
157
|
+
size, decimals = parse_type_size(col_type)
|
|
158
|
+
{
|
|
159
|
+
name: (col[:name] || col["name"]).to_s,
|
|
160
|
+
type: col_type.sub(/\(.*\)/, ""),
|
|
161
|
+
size: size,
|
|
162
|
+
decimals: decimals,
|
|
163
|
+
nullable: col.key?(:nullable) ? col[:nullable] : (col.key?("nullable") ? col["nullable"] : true),
|
|
164
|
+
primary_key: col[:primary_key] || col["primary_key"] || col[:primary] || col["primary"] || false
|
|
165
|
+
}
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def parse_type_size(type_str)
|
|
170
|
+
if (m = type_str.match(/\((\d+)(?:\s*,\s*(\d+))?\)/))
|
|
171
|
+
size = m[1].to_i
|
|
172
|
+
decimals = m[2] ? m[2].to_i : nil
|
|
173
|
+
[size, decimals]
|
|
174
|
+
else
|
|
175
|
+
[nil, nil]
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def fallback_column_info
|
|
180
|
+
return [] if @records.empty?
|
|
181
|
+
keys = @records.first.is_a?(Hash) ? @records.first.keys : []
|
|
182
|
+
keys.map do |k|
|
|
183
|
+
{
|
|
184
|
+
name: k.to_s,
|
|
185
|
+
type: "UNKNOWN",
|
|
186
|
+
size: nil,
|
|
187
|
+
decimals: nil,
|
|
188
|
+
nullable: true,
|
|
189
|
+
primary_key: false
|
|
190
|
+
}
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
80
194
|
def escape_csv(value, separator)
|
|
81
195
|
str = value.to_s
|
|
82
196
|
if str.include?(separator) || str.include?('"') || str.include?("\n")
|
data/lib/tina4/env.rb
CHANGED
data/lib/tina4/frond.rb
CHANGED
|
@@ -391,6 +391,66 @@ module Tina4
|
|
|
391
391
|
# -----------------------------------------------------------------------
|
|
392
392
|
|
|
393
393
|
def eval_var(expr, context)
|
|
394
|
+
# Check for top-level ternary BEFORE splitting filters so that
|
|
395
|
+
# expressions like ``products|length != 1 ? "s" : ""`` work correctly.
|
|
396
|
+
ternary_pos = find_ternary(expr)
|
|
397
|
+
if ternary_pos != -1
|
|
398
|
+
cond_part = expr[0...ternary_pos].strip
|
|
399
|
+
rest = expr[(ternary_pos + 1)..]
|
|
400
|
+
colon_pos = find_colon(rest)
|
|
401
|
+
if colon_pos != -1
|
|
402
|
+
true_part = rest[0...colon_pos].strip
|
|
403
|
+
false_part = rest[(colon_pos + 1)..].strip
|
|
404
|
+
cond = eval_var_raw(cond_part, context)
|
|
405
|
+
return truthy?(cond) ? eval_var(true_part, context) : eval_var(false_part, context)
|
|
406
|
+
end
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
eval_var_inner(expr, context)
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
def eval_var_raw(expr, context)
|
|
413
|
+
var_name, filters = parse_filter_chain(expr)
|
|
414
|
+
value = eval_expr(var_name, context)
|
|
415
|
+
filters.each do |fname, args|
|
|
416
|
+
next if fname == "raw" || fname == "safe"
|
|
417
|
+
fn = @filters[fname]
|
|
418
|
+
if fn
|
|
419
|
+
evaluated_args = args.map { |a| eval_filter_arg(a, context) }
|
|
420
|
+
value = fn.call(value, *evaluated_args)
|
|
421
|
+
else
|
|
422
|
+
# The filter name may include a trailing comparison operator,
|
|
423
|
+
# e.g. "length != 1". Extract the real filter name and the
|
|
424
|
+
# comparison suffix, apply the filter, then evaluate the comparison.
|
|
425
|
+
m = fname.match(/\A(\w+)\s*(!=|==|>=|<=|>|<)\s*(.+)\z/)
|
|
426
|
+
if m
|
|
427
|
+
real_filter = m[1]
|
|
428
|
+
op = m[2]
|
|
429
|
+
right_expr = m[3].strip
|
|
430
|
+
fn2 = @filters[real_filter]
|
|
431
|
+
if fn2
|
|
432
|
+
evaluated_args = args.map { |a| eval_filter_arg(a, context) }
|
|
433
|
+
value = fn2.call(value, *evaluated_args)
|
|
434
|
+
end
|
|
435
|
+
right = eval_expr(right_expr, context)
|
|
436
|
+
value = case op
|
|
437
|
+
when "!=" then value != right
|
|
438
|
+
when "==" then value == right
|
|
439
|
+
when ">=" then value >= right
|
|
440
|
+
when "<=" then value <= right
|
|
441
|
+
when ">" then value > right
|
|
442
|
+
when "<" then value < right
|
|
443
|
+
else false
|
|
444
|
+
end rescue false
|
|
445
|
+
else
|
|
446
|
+
value = eval_expr(fname, context)
|
|
447
|
+
end
|
|
448
|
+
end
|
|
449
|
+
end
|
|
450
|
+
value
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
def eval_var_inner(expr, context)
|
|
394
454
|
var_name, filters = parse_filter_chain(expr)
|
|
395
455
|
|
|
396
456
|
# Sandbox: check variable access
|
|
@@ -435,6 +495,68 @@ module Tina4
|
|
|
435
495
|
eval_expr(arg, context)
|
|
436
496
|
end
|
|
437
497
|
|
|
498
|
+
# Find the index of a top-level ``?`` that is part of a ternary operator.
|
|
499
|
+
# Respects quoted strings, parentheses, and skips ``??`` (null coalesce).
|
|
500
|
+
# Returns -1 if not found.
|
|
501
|
+
def find_ternary(expr)
|
|
502
|
+
depth = 0
|
|
503
|
+
in_quote = nil
|
|
504
|
+
i = 0
|
|
505
|
+
len = expr.length
|
|
506
|
+
while i < len
|
|
507
|
+
ch = expr[i]
|
|
508
|
+
if in_quote
|
|
509
|
+
in_quote = nil if ch == in_quote
|
|
510
|
+
i += 1
|
|
511
|
+
next
|
|
512
|
+
end
|
|
513
|
+
if ch == '"' || ch == "'"
|
|
514
|
+
in_quote = ch
|
|
515
|
+
i += 1
|
|
516
|
+
next
|
|
517
|
+
end
|
|
518
|
+
if ch == "("
|
|
519
|
+
depth += 1
|
|
520
|
+
elsif ch == ")"
|
|
521
|
+
depth -= 1
|
|
522
|
+
elsif ch == "?" && depth == 0
|
|
523
|
+
# Skip ``??`` (null coalesce)
|
|
524
|
+
if i + 1 < len && expr[i + 1] == "?"
|
|
525
|
+
i += 2
|
|
526
|
+
next
|
|
527
|
+
end
|
|
528
|
+
return i
|
|
529
|
+
end
|
|
530
|
+
i += 1
|
|
531
|
+
end
|
|
532
|
+
-1
|
|
533
|
+
end
|
|
534
|
+
|
|
535
|
+
# Find the index of the top-level ``:`` that separates the true/false
|
|
536
|
+
# branches of a ternary. Respects quotes and parentheses.
|
|
537
|
+
def find_colon(expr)
|
|
538
|
+
depth = 0
|
|
539
|
+
in_quote = nil
|
|
540
|
+
expr.each_char.with_index do |ch, i|
|
|
541
|
+
if in_quote
|
|
542
|
+
in_quote = nil if ch == in_quote
|
|
543
|
+
next
|
|
544
|
+
end
|
|
545
|
+
if ch == '"' || ch == "'"
|
|
546
|
+
in_quote = ch
|
|
547
|
+
next
|
|
548
|
+
end
|
|
549
|
+
if ch == "("
|
|
550
|
+
depth += 1
|
|
551
|
+
elsif ch == ")"
|
|
552
|
+
depth -= 1
|
|
553
|
+
elsif ch == ":" && depth == 0
|
|
554
|
+
return i
|
|
555
|
+
end
|
|
556
|
+
end
|
|
557
|
+
-1
|
|
558
|
+
end
|
|
559
|
+
|
|
438
560
|
# -----------------------------------------------------------------------
|
|
439
561
|
# Filter chain parser
|
|
440
562
|
# -----------------------------------------------------------------------
|
|
@@ -1321,8 +1443,20 @@ module Tina4
|
|
|
1321
1443
|
"safe" => ->(v, *_a) { v },
|
|
1322
1444
|
"json_encode" => ->(v, *_a) { JSON.generate(v) rescue v.to_s },
|
|
1323
1445
|
"json_decode" => ->(v, *_a) { v.is_a?(String) ? (JSON.parse(v) rescue v) : v },
|
|
1324
|
-
"base64_encode" => ->(v, *_a) { Base64.strict_encode64(v.to_s) },
|
|
1446
|
+
"base64_encode" => ->(v, *_a) { Base64.strict_encode64(v.is_a?(String) ? v : v.to_s) },
|
|
1447
|
+
"base64encode" => ->(v, *_a) { Base64.strict_encode64(v.is_a?(String) ? v : v.to_s) },
|
|
1325
1448
|
"base64_decode" => ->(v, *_a) { Base64.decode64(v.to_s) },
|
|
1449
|
+
"base64decode" => ->(v, *_a) { Base64.decode64(v.to_s) },
|
|
1450
|
+
"data_uri" => ->(v, *_a) {
|
|
1451
|
+
if v.is_a?(Hash)
|
|
1452
|
+
ct = v[:type] || v["type"] || "application/octet-stream"
|
|
1453
|
+
raw = v[:content] || v["content"] || ""
|
|
1454
|
+
raw = raw.respond_to?(:read) ? raw.read : raw
|
|
1455
|
+
"data:#{ct};base64,#{Base64.strict_encode64(raw.to_s)}"
|
|
1456
|
+
else
|
|
1457
|
+
v.to_s
|
|
1458
|
+
end
|
|
1459
|
+
},
|
|
1326
1460
|
"url_encode" => ->(v, *_a) { CGI.escape(v.to_s) },
|
|
1327
1461
|
|
|
1328
1462
|
# -- Hashing --
|
|
@@ -1473,6 +1607,14 @@ module Tina4
|
|
|
1473
1607
|
# - "checkout|order_123": payload is {"type" => "form", "context" => "checkout", "ref" => "order_123"}
|
|
1474
1608
|
#
|
|
1475
1609
|
# @return [String] <input type="hidden" name="formToken" value="TOKEN">
|
|
1610
|
+
# Session ID used by generate_form_token for CSRF session binding.
|
|
1611
|
+
# Set this before rendering templates to bind tokens to the current session.
|
|
1612
|
+
@form_token_session_id = ""
|
|
1613
|
+
|
|
1614
|
+
class << self
|
|
1615
|
+
attr_accessor :form_token_session_id
|
|
1616
|
+
end
|
|
1617
|
+
|
|
1476
1618
|
def self.generate_form_token(descriptor = "")
|
|
1477
1619
|
require_relative "log"
|
|
1478
1620
|
require_relative "auth"
|
|
@@ -1488,7 +1630,11 @@ module Tina4
|
|
|
1488
1630
|
end
|
|
1489
1631
|
end
|
|
1490
1632
|
|
|
1491
|
-
|
|
1633
|
+
# Include session_id for CSRF session binding
|
|
1634
|
+
sid = form_token_session_id.to_s
|
|
1635
|
+
payload["session_id"] = sid unless sid.empty?
|
|
1636
|
+
|
|
1637
|
+
ttl_minutes = (ENV["TINA4_TOKEN_LIMIT"] || "60").to_i
|
|
1492
1638
|
expires_in = ttl_minutes * 60
|
|
1493
1639
|
token = Tina4::Auth.create_token(payload, expires_in: expires_in)
|
|
1494
1640
|
Tina4::SafeString.new(%(<input type="hidden" name="formToken" value="#{CGI.escapeHTML(token)}">))
|