tina4ruby 3.2.1 → 3.10.0

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.
@@ -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
- @driver = create_driver
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
- connect
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
- @driver.close if @connected
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, skip: 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 = @driver.apply_limit(effective_sql, limit, skip || 0)
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 = @driver.execute_query(effective_sql, params)
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 = @driver.execute_query(effective_sql, params)
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 = @driver.placeholders(keys.length)
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 = @driver.placeholders(columns.length)
233
+ placeholders = drv.placeholders(columns.length)
134
234
  sql = "INSERT INTO #{table} (#{columns.join(', ')}) VALUES (#{placeholders})"
135
- @driver.execute(sql, data.values)
136
- { success: true, last_id: @driver.last_insert_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} = #{@driver.placeholder}" }
143
- where_parts = filter.keys.map { |k| "#{k} = #{@driver.placeholder}" }
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
- @driver.execute(sql, values)
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
- @driver.execute(sql)
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} = #{@driver.placeholder}" }
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
- @driver.execute(sql, filter.values)
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
- @driver.execute(sql, params)
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
- @driver.execute(sql, params)
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
- @driver.begin_transaction
293
+ drv = current_driver
294
+ drv.begin_transaction
192
295
  yield self
193
- @driver.commit
296
+ drv.commit
194
297
  rescue => e
195
- @driver.rollback
298
+ drv.rollback
196
299
  raise e
197
300
  end
198
301
 
199
302
  def tables
200
- @driver.tables
303
+ current_driver.tables
201
304
  end
202
305
 
203
306
  def columns(table_name)
204
- @driver.columns(table_name)
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, :sql, :count
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
- @count = @records.length
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: 1, per_page: 10)
58
- total = @records.length
59
- total_pages = (total.to_f / per_page).ceil
60
- offset = (page - 1) * per_page
61
- page_records = @records[offset, per_page] || []
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
@@ -6,7 +6,7 @@ module Tina4
6
6
  DEFAULT_ENV = {
7
7
  "PROJECT_NAME" => "Tina4 Ruby Project",
8
8
  "VERSION" => "1.0.0",
9
- "TINA4_LANGUAGE" => "en",
9
+ "TINA4_LOCALE" => "en",
10
10
  "TINA4_DEBUG" => "true",
11
11
  "TINA4_LOG_LEVEL" => "[TINA4_LOG_ALL]",
12
12
  "SECRET" => "tina4-secret-change-me"
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
- ttl_minutes = (ENV["TINA4_TOKEN_LIMIT"] || "30").to_i
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)}">))
@@ -11,7 +11,7 @@ module Tina4
11
11
  end
12
12
 
13
13
  def current_locale
14
- @current_locale || ENV["TINA4_LANGUAGE"] || "en"
14
+ @current_locale || ENV["TINA4_LOCALE"] || "en"
15
15
  end
16
16
 
17
17
  def current_locale=(locale)