tina4ruby 3.0.0 → 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.
Files changed (41) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +120 -32
  3. data/lib/tina4/auth.rb +137 -27
  4. data/lib/tina4/auto_crud.rb +55 -3
  5. data/lib/tina4/cli.rb +228 -28
  6. data/lib/tina4/cors.rb +1 -1
  7. data/lib/tina4/database.rb +230 -26
  8. data/lib/tina4/database_result.rb +122 -8
  9. data/lib/tina4/dev_mailbox.rb +1 -1
  10. data/lib/tina4/env.rb +1 -1
  11. data/lib/tina4/frond.rb +314 -7
  12. data/lib/tina4/gallery/queue/meta.json +1 -1
  13. data/lib/tina4/gallery/queue/src/routes/api/gallery_queue.rb +314 -16
  14. data/lib/tina4/localization.rb +1 -1
  15. data/lib/tina4/messenger.rb +111 -33
  16. data/lib/tina4/middleware.rb +349 -1
  17. data/lib/tina4/migration.rb +132 -11
  18. data/lib/tina4/orm.rb +149 -18
  19. data/lib/tina4/public/js/tina4-dev-admin.min.js +1 -1
  20. data/lib/tina4/public/js/tina4js.min.js +47 -0
  21. data/lib/tina4/query_builder.rb +374 -0
  22. data/lib/tina4/queue.rb +219 -61
  23. data/lib/tina4/queue_backends/lite_backend.rb +42 -7
  24. data/lib/tina4/queue_backends/mongo_backend.rb +126 -0
  25. data/lib/tina4/rack_app.rb +200 -11
  26. data/lib/tina4/request.rb +14 -1
  27. data/lib/tina4/response.rb +26 -0
  28. data/lib/tina4/response_cache.rb +446 -29
  29. data/lib/tina4/router.rb +127 -0
  30. data/lib/tina4/service_runner.rb +1 -1
  31. data/lib/tina4/session.rb +6 -1
  32. data/lib/tina4/session_handlers/database_handler.rb +66 -0
  33. data/lib/tina4/swagger.rb +1 -1
  34. data/lib/tina4/templates/errors/404.twig +2 -2
  35. data/lib/tina4/templates/errors/500.twig +1 -1
  36. data/lib/tina4/validator.rb +174 -0
  37. data/lib/tina4/version.rb +1 -1
  38. data/lib/tina4/websocket.rb +23 -4
  39. data/lib/tina4/websocket_backplane.rb +118 -0
  40. data/lib/tina4.rb +126 -5
  41. metadata +40 -3
@@ -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")
@@ -34,7 +34,7 @@ module Tina4
34
34
  attachments: store_attachments(msg_id, attachments),
35
35
  read: false,
36
36
  folder: "outbox",
37
- created_at: timestamp.iso8601,
37
+ created_at: timestamp.strftime("%Y-%m-%dT%H:%M:%S.%6N%:z"),
38
38
  updated_at: timestamp.iso8601
39
39
  }
40
40
 
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
@@ -56,21 +56,65 @@ module Tina4
56
56
  # Fragment cache: key => [html, expires_at]
57
57
  @fragment_cache = {}
58
58
 
59
+ # Token pre-compilation cache
60
+ @compiled = {} # {template_name => [tokens, mtime]}
61
+ @compiled_strings = {} # {md5_hash => tokens}
62
+
59
63
  # Built-in global functions
60
64
  register_builtin_globals
61
65
  end
62
66
 
63
- # Render a template file with data.
67
+ # Render a template file with data. Uses token caching for performance.
64
68
  def render(template, data = {})
65
69
  context = @globals.merge(stringify_keys(data))
66
- source = load_template(template)
67
- execute(source, context)
70
+
71
+ path = File.join(@template_dir, template)
72
+ raise "Template not found: #{path}" unless File.exist?(path)
73
+
74
+ debug_mode = ENV.fetch("TINA4_DEBUG", "").downcase == "true"
75
+ cached = @compiled[template]
76
+
77
+ if cached
78
+ if debug_mode
79
+ # Dev mode: check if file changed
80
+ mtime = File.mtime(path)
81
+ if cached[1] == mtime
82
+ return execute_cached(cached[0], context)
83
+ end
84
+ else
85
+ # Production: skip mtime check, cache is permanent
86
+ return execute_cached(cached[0], context)
87
+ end
88
+ end
89
+
90
+ # Cache miss — load, tokenize, cache
91
+ source = File.read(path, encoding: "utf-8")
92
+ mtime = File.mtime(path)
93
+ tokens = tokenize(source)
94
+ @compiled[template] = [tokens, mtime]
95
+ execute_with_tokens(source, tokens, context)
68
96
  end
69
97
 
70
- # Render a template string directly.
98
+ # Render a template string directly. Uses token caching for performance.
71
99
  def render_string(source, data = {})
72
100
  context = @globals.merge(stringify_keys(data))
73
- execute(source, context)
101
+
102
+ key = Digest::MD5.hexdigest(source)
103
+ cached_tokens = @compiled_strings[key]
104
+
105
+ if cached_tokens
106
+ return execute_cached(cached_tokens, context)
107
+ end
108
+
109
+ tokens = tokenize(source)
110
+ @compiled_strings[key] = tokens
111
+ execute_cached(tokens, context)
112
+ end
113
+
114
+ # Clear all compiled template caches.
115
+ def clear_cache
116
+ @compiled.clear
117
+ @compiled_strings.clear
74
118
  end
75
119
 
76
120
  # Register a custom filter.
@@ -198,6 +242,35 @@ module Tina4
198
242
  # Execution
199
243
  # -----------------------------------------------------------------------
200
244
 
245
+ def execute_cached(tokens, context)
246
+ # Check if first non-text token is an extends block
247
+ tokens.each do |ttype, raw|
248
+ next if ttype == TEXT && raw.strip.empty?
249
+ if ttype == BLOCK
250
+ content, _, _ = strip_tag(raw)
251
+ if content.start_with?("extends ")
252
+ # Extends requires source-based execution for block extraction
253
+ source = tokens.map { |_, v| v }.join
254
+ return execute(source, context)
255
+ end
256
+ end
257
+ break
258
+ end
259
+ render_tokens(tokens, context)
260
+ end
261
+
262
+ def execute_with_tokens(source, tokens, context)
263
+ # Handle extends first
264
+ if source =~ /\{%-?\s*extends\s+["'](.+?)["']\s*-?%\}/
265
+ parent_name = Regexp.last_match(1)
266
+ parent_source = load_template(parent_name)
267
+ child_blocks = extract_blocks(source)
268
+ return render_with_blocks(parent_source, context, child_blocks)
269
+ end
270
+
271
+ render_tokens(tokens, context)
272
+ end
273
+
201
274
  def execute(source, context)
202
275
  # Handle extends first
203
276
  if source =~ /\{%-?\s*extends\s+["'](.+?)["']\s*-?%\}/
@@ -290,6 +363,12 @@ module Tina4
290
363
  when "cache"
291
364
  result, i = handle_cache(tokens, i, context)
292
365
  output << result
366
+ when "spaceless"
367
+ result, i = handle_spaceless(tokens, i, context)
368
+ output << result
369
+ when "autoescape"
370
+ result, i = handle_autoescape(tokens, i, context)
371
+ output << result
293
372
  when "block", "endblock", "extends"
294
373
  i += 1
295
374
  else
@@ -312,6 +391,66 @@ module Tina4
312
391
  # -----------------------------------------------------------------------
313
392
 
314
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)
315
454
  var_name, filters = parse_filter_chain(expr)
316
455
 
317
456
  # Sandbox: check variable access
@@ -356,6 +495,68 @@ module Tina4
356
495
  eval_expr(arg, context)
357
496
  end
358
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
+
359
560
  # -----------------------------------------------------------------------
360
561
  # Filter chain parser
361
562
  # -----------------------------------------------------------------------
@@ -496,6 +697,13 @@ module Tina4
496
697
  return truthy?(cond) ? eval_expr(ternary[2], context) : eval_expr(ternary[3], context)
497
698
  end
498
699
 
700
+ # Jinja2-style inline if: value if condition else other_value
701
+ inline_if = expr.match(/\A(.+?)\s+if\s+(.+?)\s+else\s+(.+)\z/)
702
+ if inline_if
703
+ cond = eval_expr(inline_if[2], context)
704
+ return truthy?(cond) ? eval_expr(inline_if[1], context) : eval_expr(inline_if[3], context)
705
+ end
706
+
499
707
  # Null coalescing: value ?? "default"
500
708
  if expr.include?("??")
501
709
  left, _, right = expr.partition("??")
@@ -1121,6 +1329,81 @@ module Tina4
1121
1329
  [rendered, i]
1122
1330
  end
1123
1331
 
1332
+ def handle_spaceless(tokens, start, context)
1333
+ body_tokens = []
1334
+ i = start + 1
1335
+ depth = 0
1336
+ while i < tokens.length
1337
+ if tokens[i][0] == BLOCK
1338
+ tc, _, _ = strip_tag(tokens[i][1])
1339
+ tag = tc.split[0] || ""
1340
+ if tag == "spaceless"
1341
+ depth += 1
1342
+ body_tokens << tokens[i]
1343
+ elsif tag == "endspaceless"
1344
+ if depth == 0
1345
+ i += 1
1346
+ break
1347
+ end
1348
+ depth -= 1
1349
+ body_tokens << tokens[i]
1350
+ else
1351
+ body_tokens << tokens[i]
1352
+ end
1353
+ else
1354
+ body_tokens << tokens[i]
1355
+ end
1356
+ i += 1
1357
+ end
1358
+
1359
+ rendered = render_tokens(body_tokens.dup, context)
1360
+ rendered = rendered.gsub(/>\s+</, "><")
1361
+ [rendered, i]
1362
+ end
1363
+
1364
+ def handle_autoescape(tokens, start, context)
1365
+ content, _, _ = strip_tag(tokens[start][1])
1366
+ mode_match = content.match(/\Aautoescape\s+(false|true)/)
1367
+ auto_escape_on = !(mode_match && mode_match[1] == "false")
1368
+
1369
+ body_tokens = []
1370
+ i = start + 1
1371
+ depth = 0
1372
+ while i < tokens.length
1373
+ if tokens[i][0] == BLOCK
1374
+ tc, _, _ = strip_tag(tokens[i][1])
1375
+ tag = tc.split[0] || ""
1376
+ if tag == "autoescape"
1377
+ depth += 1
1378
+ body_tokens << tokens[i]
1379
+ elsif tag == "endautoescape"
1380
+ if depth == 0
1381
+ i += 1
1382
+ break
1383
+ end
1384
+ depth -= 1
1385
+ body_tokens << tokens[i]
1386
+ else
1387
+ body_tokens << tokens[i]
1388
+ end
1389
+ else
1390
+ body_tokens << tokens[i]
1391
+ end
1392
+ i += 1
1393
+ end
1394
+
1395
+ if !auto_escape_on
1396
+ old_auto_escape = @auto_escape
1397
+ @auto_escape = false
1398
+ rendered = render_tokens(body_tokens.dup, context)
1399
+ @auto_escape = old_auto_escape
1400
+ else
1401
+ rendered = render_tokens(body_tokens.dup, context)
1402
+ end
1403
+
1404
+ [rendered, i]
1405
+ end
1406
+
1124
1407
  # -----------------------------------------------------------------------
1125
1408
  # Helpers
1126
1409
  # -----------------------------------------------------------------------
@@ -1160,8 +1443,20 @@ module Tina4
1160
1443
  "safe" => ->(v, *_a) { v },
1161
1444
  "json_encode" => ->(v, *_a) { JSON.generate(v) rescue v.to_s },
1162
1445
  "json_decode" => ->(v, *_a) { v.is_a?(String) ? (JSON.parse(v) rescue v) : v },
1163
- "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) },
1164
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
+ },
1165
1460
  "url_encode" => ->(v, *_a) { CGI.escape(v.to_s) },
1166
1461
 
1167
1462
  # -- Hashing --
@@ -1312,6 +1607,14 @@ module Tina4
1312
1607
  # - "checkout|order_123": payload is {"type" => "form", "context" => "checkout", "ref" => "order_123"}
1313
1608
  #
1314
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
+
1315
1618
  def self.generate_form_token(descriptor = "")
1316
1619
  require_relative "log"
1317
1620
  require_relative "auth"
@@ -1327,7 +1630,11 @@ module Tina4
1327
1630
  end
1328
1631
  end
1329
1632
 
1330
- 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
1331
1638
  expires_in = ttl_minutes * 60
1332
1639
  token = Tina4::Auth.create_token(payload, expires_in: expires_in)
1333
1640
  Tina4::SafeString.new(%(<input type="hidden" name="formToken" value="#{CGI.escapeHTML(token)}">))
@@ -1 +1 @@
1
- {"name": "Queue", "description": "Background job producer and consumer", "try_url": "/api/gallery/queue/produce"}
1
+ {"name": "Queue", "description": "Background job producer and consumer", "try_url": "/gallery/queue"}