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.
@@ -19,6 +19,9 @@ module Tina4
19
19
  .select { |d| Dir.exist?(d) }
20
20
  fallback = Dir.exist?(FRAMEWORK_PUBLIC_DIR) ? [FRAMEWORK_PUBLIC_DIR] : []
21
21
  @static_roots = (project_roots + fallback).freeze
22
+
23
+ # Shared WebSocket engine for route-based WS handling
24
+ @websocket_engine = Tina4::WebSocket.new
22
25
  end
23
26
 
24
27
  def call(env)
@@ -29,6 +32,15 @@ module Tina4
29
32
  # Fast-path: OPTIONS preflight
30
33
  return Tina4::CorsMiddleware.preflight_response(env) if method == "OPTIONS"
31
34
 
35
+ # WebSocket upgrade — match against registered ws_routes
36
+ if websocket_upgrade?(env)
37
+ ws_result = Tina4::Router.find_ws_route(path)
38
+ if ws_result
39
+ ws_route, ws_params = ws_result
40
+ return handle_websocket_upgrade(env, ws_route, ws_params)
41
+ end
42
+ end
43
+
32
44
  # Dev dashboard routes (handled before anything else)
33
45
  if path.start_with?("/__dev")
34
46
  dev_response = Tina4::DevAdmin.handle_request(env)
@@ -88,6 +100,23 @@ module Tina4
88
100
  end
89
101
  end
90
102
 
103
+ # Save session and set cookie if session was used
104
+ if result && defined?(rack_response)
105
+ status, headers, body_parts = rack_response
106
+ request_obj = env["tina4.request"]
107
+ if request_obj&.instance_variable_get(:@session)
108
+ sess = request_obj.session
109
+ sess.save
110
+ sid = sess.id
111
+ cookie_val = (env["HTTP_COOKIE"] || "")[/tina4_session=([^;]+)/, 1]
112
+ if sid && sid != cookie_val
113
+ ttl = Integer(ENV.fetch("TINA4_SESSION_TTL", 3600))
114
+ headers["set-cookie"] = "tina4_session=#{sid}; Path=/; HttpOnly; SameSite=Lax; Max-Age=#{ttl}"
115
+ end
116
+ rack_response = [status, headers, body_parts]
117
+ end
118
+ end
119
+
91
120
  rack_response
92
121
  rescue => e
93
122
  handle_500(e, env)
@@ -96,15 +125,43 @@ module Tina4
96
125
  private
97
126
 
98
127
  def handle_route(env, route, path_params)
99
- # Auth check
128
+ # Auth check (legacy per-route auth_handler)
100
129
  if route.auth_handler
101
130
  auth_result = route.auth_handler.call(env)
102
131
  return handle_403(env["PATH_INFO"] || "/") unless auth_result
103
132
  end
104
133
 
134
+ # Secure-by-default: enforce bearer-token auth on write routes
135
+ if route.auth_required
136
+ auth_header = env["HTTP_AUTHORIZATION"] || ""
137
+ token = auth_header =~ /\ABearer\s+(.+)\z/i ? Regexp.last_match(1) : nil
138
+
139
+ # API_KEY bypass — matches tina4_python behavior
140
+ api_key = ENV["TINA4_API_KEY"] || ENV["API_KEY"]
141
+ if api_key && !api_key.empty? && token == api_key
142
+ env["tina4.auth_payload"] = { "api_key" => true }
143
+ elsif token
144
+ payload = Tina4::Auth.valid_token(token)
145
+ unless payload
146
+ return [401, { "content-type" => "application/json" }, [JSON.generate({ error: "Unauthorized" })]]
147
+ end
148
+ env["tina4.auth_payload"] = payload
149
+ else
150
+ return [401, { "content-type" => "application/json" }, [JSON.generate({ error: "Unauthorized" })]]
151
+ end
152
+ end
153
+
105
154
  request = Tina4::Request.new(env, path_params)
155
+ request.user = env["tina4.auth_payload"] if env["tina4.auth_payload"]
156
+ env["tina4.request"] = request # Store for session save after response
106
157
  response = Tina4::Response.new
107
158
 
159
+ # Run global middleware (block-based + class-based before_* methods)
160
+ unless Tina4::Middleware.run_before(request, response)
161
+ # Middleware halted the request -- return whatever response was set
162
+ return response.to_rack
163
+ end
164
+
108
165
  # Run per-route middleware
109
166
  if route.respond_to?(:run_middleware)
110
167
  unless route.run_middleware(request, response)
@@ -112,21 +169,19 @@ module Tina4
112
169
  end
113
170
  end
114
171
 
115
- # Execute handler — support (), (response), (request), or (request, response) signatures
116
- # When 1 param: if named :request or :req, pass request; otherwise pass response
117
- result = case route.handler.arity
118
- when 0
119
- route.handler.call
120
- when 1
121
- param_name = route.handler.parameters.first&.last
122
- if param_name == :request || param_name == :req
123
- route.handler.call(request)
172
+ # Execute handler — inject path params by name, then request/response
173
+ handler_params = route.handler.parameters.map(&:last)
174
+ route_params = path_params || {}
175
+ args = handler_params.map do |name|
176
+ if route_params.key?(name)
177
+ route_params[name]
178
+ elsif name == :request || name == :req
179
+ request
124
180
  else
125
- route.handler.call(response)
181
+ response
126
182
  end
127
- else
128
- route.handler.call(request, response)
129
183
  end
184
+ result = args.empty? ? route.handler.call : route.handler.call(*args)
130
185
 
131
186
  # Template rendering: when a template is set and the handler returned a Hash,
132
187
  # render the template with the hash as data and return the HTML response.
@@ -138,6 +193,10 @@ module Tina4
138
193
 
139
194
  # Skip auto_detect if handler already returned the response object
140
195
  final_response = result.equal?(response) ? result : Tina4::Response.auto_detect(result, response)
196
+
197
+ # Run global after middleware (block-based + class-based after_* methods)
198
+ Tina4::Middleware.run_after(request, final_response)
199
+
141
200
  final_response.to_rack
142
201
  end
143
202
 
@@ -199,10 +258,12 @@ module Tina4
199
258
  end
200
259
 
201
260
  def handle_404(path)
202
- # Show landing page for GET "/" when no user route or template index exists
203
- if path == "/" && should_show_landing_page?
204
- return render_landing_page
205
- end
261
+ # Try serving a template file (e.g. /hello -> src/templates/hello.twig or hello.html)
262
+ template_response = try_serve_template(path)
263
+ return template_response if template_response
264
+
265
+ # Show landing page for GET "/"
266
+ return render_landing_page if path == "/"
206
267
 
207
268
  Tina4::Log.warning("404 Not Found: #{path}")
208
269
  body = Tina4::Template.render_error(404, { "path" => path }) rescue "404 Not Found"
@@ -215,6 +276,62 @@ module Tina4
215
276
  %w[index.html index.twig index.erb].none? { |f| File.file?(File.join(templates_dir, f)) }
216
277
  end
217
278
 
279
+ def try_serve_template(path)
280
+ tpl_file = resolve_template(path)
281
+ return nil unless tpl_file
282
+
283
+ templates_dir = File.join(@root_dir, "src", "templates")
284
+ body = Tina4::Template.render(tpl_file, {}) rescue File.read(File.join(templates_dir, tpl_file))
285
+ [200, { "content-type" => "text/html" }, [body]]
286
+ end
287
+
288
+ # Resolve a URL path to a template file.
289
+ # Dev mode: checks filesystem every time for live changes.
290
+ # Production: uses a cached lookup built once at startup.
291
+ def resolve_template(path)
292
+ clean_path = path.sub(%r{^/}, "")
293
+ clean_path = "index" if clean_path.empty?
294
+ is_dev = %w[true 1 yes].include?(ENV.fetch("TINA4_DEBUG", "false").downcase)
295
+
296
+ if is_dev
297
+ templates_dir = File.join(@root_dir, "src", "templates")
298
+ %w[.twig .html].each do |ext|
299
+ candidate = clean_path + ext
300
+ return candidate if File.file?(File.join(templates_dir, candidate))
301
+ end
302
+ return nil
303
+ end
304
+
305
+ # Production: cached lookup
306
+ @template_cache ||= build_template_cache
307
+ @template_cache[clean_path]
308
+ end
309
+
310
+ def build_template_cache
311
+ cache = {}
312
+ templates_dir = File.join(@root_dir, "src", "templates")
313
+ return cache unless File.directory?(templates_dir)
314
+
315
+ Dir.glob(File.join(templates_dir, "**", "*.{twig,html}")).each do |f|
316
+ rel = f.sub(templates_dir + File::SEPARATOR, "").tr("\\", "/")
317
+ url_path = rel.sub(/\.(twig|html)$/, "")
318
+ cache[url_path] ||= rel
319
+ end
320
+ cache
321
+ end
322
+
323
+ def try_serve_index_template
324
+ templates_dir = File.join(@root_dir, "src", "templates")
325
+ %w[index.html index.twig index.erb].each do |f|
326
+ path = File.join(templates_dir, f)
327
+ if File.file?(path)
328
+ body = Tina4::Template.render(f, {}) rescue File.read(path)
329
+ return [200, { "content-type" => "text/html" }, [body]]
330
+ end
331
+ end
332
+ nil
333
+ end
334
+
218
335
  def render_landing_page
219
336
  port = ENV["PORT"] || "7145"
220
337
 
@@ -363,7 +480,22 @@ module Tina4
363
480
  btn.disabled = false;
364
481
  btn.textContent = 'Deploy & Try';
365
482
  } else {
366
- window.location.href = tryUrl;
483
+ // Wait for the newly deployed route to become reachable before navigating
484
+ var attempts = 0;
485
+ var maxAttempts = 5;
486
+ function pollRoute() {
487
+ fetch(tryUrl, {method: 'HEAD'}).then(function() {
488
+ window.location.href = tryUrl;
489
+ }).catch(function() {
490
+ attempts++;
491
+ if (attempts < maxAttempts) {
492
+ setTimeout(pollRoute, 500);
493
+ } else {
494
+ window.location.href = tryUrl;
495
+ }
496
+ });
497
+ }
498
+ setTimeout(pollRoute, 500);
367
499
  }
368
500
  }).catch(function(e) {
369
501
  alert('Deploy error: ' + e.message);
@@ -420,6 +552,50 @@ module Tina4
420
552
  Tina4::Env.truthy?(ENV["TINA4_DEBUG"])
421
553
  end
422
554
 
555
+ def websocket_upgrade?(env)
556
+ upgrade = env["HTTP_UPGRADE"] || ""
557
+ upgrade.downcase == "websocket"
558
+ end
559
+
560
+ def handle_websocket_upgrade(env, ws_route, ws_params)
561
+ # Rack hijack is required for WebSocket upgrades
562
+ unless env["rack.hijack"]
563
+ Tina4::Log.warning("WebSocket upgrade requested but rack.hijack not available")
564
+ return [426, { "content-type" => "text/plain" }, ["WebSocket upgrade requires rack.hijack support"]]
565
+ end
566
+
567
+ env["rack.hijack"].call
568
+ socket = env["rack.hijack_io"]
569
+
570
+ # Wire the route handler into the WebSocket engine events
571
+ handler = ws_route.handler
572
+
573
+ # Create a dedicated WebSocket engine for this route so handlers stay isolated
574
+ ws = Tina4::WebSocket.new
575
+
576
+ ws.on(:open) do |connection|
577
+ connection.params = ws_params
578
+ handler.call(connection, :open, nil)
579
+ end
580
+
581
+ ws.on(:message) do |connection, data|
582
+ handler.call(connection, :message, data)
583
+ end
584
+
585
+ ws.on(:close) do |connection|
586
+ handler.call(connection, :close, nil)
587
+ end
588
+
589
+ ws.on(:error) do |connection, error|
590
+ Tina4::Log.error("WebSocket error on #{ws_route.path}: #{error.message}")
591
+ end
592
+
593
+ ws.handle_upgrade(env, socket)
594
+
595
+ # Return async response (-1 signals Rack the response is handled via hijack)
596
+ [-1, {}, []]
597
+ end
598
+
423
599
  def inject_dev_overlay(body, request_info)
424
600
  version = Tina4::VERSION
425
601
  method = request_info[:method]
data/lib/tina4/request.rb CHANGED
@@ -6,6 +6,12 @@ module Tina4
6
6
  class Request
7
7
  attr_reader :env, :method, :path, :query_string, :content_type,
8
8
  :path_params, :ip
9
+ attr_accessor :user
10
+
11
+ # Maximum upload size in bytes (default 10 MB). Override via TINA4_MAX_UPLOAD_SIZE env var.
12
+ TINA4_MAX_UPLOAD_SIZE = Integer(ENV.fetch("TINA4_MAX_UPLOAD_SIZE", 10_485_760))
13
+
14
+ class PayloadTooLarge < StandardError; end
9
15
 
10
16
  def initialize(env, path_params = {})
11
17
  @env = env
@@ -15,6 +21,13 @@ module Tina4
15
21
  @content_type = env["CONTENT_TYPE"] || ""
16
22
  @path_params = path_params
17
23
 
24
+ # Check upload size limit
25
+ content_length = (env["CONTENT_LENGTH"] || 0).to_i
26
+ if content_length > TINA4_MAX_UPLOAD_SIZE
27
+ raise PayloadTooLarge,
28
+ "Request body (#{content_length} bytes) exceeds TINA4_MAX_UPLOAD_SIZE (#{TINA4_MAX_UPLOAD_SIZE} bytes)"
29
+ end
30
+
18
31
  # Client IP with X-Forwarded-For support
19
32
  @ip = extract_client_ip
20
33
 
@@ -52,7 +65,7 @@ module Tina4
52
65
  end
53
66
 
54
67
  def session
55
- @session ||= @env["tina4.session"] || {}
68
+ @session ||= Tina4::Session.new(@env)
56
69
  end
57
70
 
58
71
  # Raw body string
@@ -110,6 +110,32 @@ module Tina4
110
110
  self
111
111
  end
112
112
 
113
+ # Standard error response envelope.
114
+ #
115
+ # Usage:
116
+ # response.error("VALIDATION_FAILED", "Email is required", 400)
117
+ #
118
+ def error(code, message, status_code = 400)
119
+ @status_code = status_code
120
+ @headers["content-type"] = JSON_CONTENT_TYPE
121
+ @body = JSON.generate({
122
+ error: true,
123
+ code: code,
124
+ message: message,
125
+ status: status_code
126
+ })
127
+ self
128
+ end
129
+
130
+ # Build a standard error envelope hash (class method).
131
+ #
132
+ # Usage:
133
+ # response.json(Tina4::Response.error_envelope("NOT_FOUND", "Resource not found", 404), status: 404)
134
+ #
135
+ def self.error_envelope(code, message, status = 400)
136
+ { error: true, code: code, message: message, status: status }
137
+ end
138
+
113
139
  # Chainable header setter
114
140
  def header(name, value = nil)
115
141
  if value.nil?
data/lib/tina4/router.rb CHANGED
@@ -4,6 +4,7 @@ module Tina4
4
4
  class Route
5
5
  attr_reader :method, :path, :handler, :auth_handler, :swagger_meta,
6
6
  :path_regex, :param_names, :middleware, :template
7
+ attr_accessor :auth_required, :cached
7
8
 
8
9
  def initialize(method, path, handler, auth_handler: nil, swagger_meta: {}, middleware: [], template: nil)
9
10
  @method = method.to_s.upcase.freeze
@@ -13,11 +14,34 @@ module Tina4
13
14
  @swagger_meta = swagger_meta
14
15
  @middleware = middleware.freeze
15
16
  @template = template&.freeze
17
+ @auth_required = %w[POST PUT PATCH DELETE].include?(@method)
18
+ @cached = false
16
19
  @param_names = []
17
20
  @path_regex = compile_pattern(@path)
18
21
  @param_names.freeze
19
22
  end
20
23
 
24
+ # Mark this route as requiring bearer-token authentication.
25
+ # Returns self for chaining: Router.get("/path") { ... }.secure
26
+ def secure
27
+ @auth_required = true
28
+ self
29
+ end
30
+
31
+ # Opt out of the secure-by-default auth on write routes.
32
+ # Returns self for chaining: Router.post("/login") { ... }.no_auth
33
+ def no_auth
34
+ @auth_required = false
35
+ self
36
+ end
37
+
38
+ # Mark this route as cacheable.
39
+ # Returns self for chaining: Router.get("/path") { ... }.cache
40
+ def cache
41
+ @cached = true
42
+ self
43
+ end
44
+
21
45
  # Returns params hash if matched, false otherwise
22
46
  def match?(request_path, request_method = nil)
23
47
  return false if request_method && @method != "ANY" && @method != request_method.to_s.upcase
@@ -105,12 +129,98 @@ module Tina4
105
129
  end
106
130
  end
107
131
 
132
+ # A registered WebSocket route with path pattern matching (reuses Route's compile logic)
133
+ class WebSocketRoute
134
+ attr_reader :path, :handler, :path_regex, :param_names
135
+
136
+ def initialize(path, handler)
137
+ @path = normalize_path(path).freeze
138
+ @handler = handler
139
+ @param_names = []
140
+ @path_regex = compile_pattern(@path)
141
+ @param_names.freeze
142
+ end
143
+
144
+ # Returns params hash if matched, false otherwise
145
+ def match?(request_path)
146
+ match = @path_regex.match(request_path)
147
+ return false unless match
148
+
149
+ if @param_names.empty?
150
+ {}
151
+ else
152
+ params = {}
153
+ @param_names.each_with_index do |param_def, i|
154
+ raw_value = match[i + 1]
155
+ params[param_def[:name]] = raw_value
156
+ end
157
+ params
158
+ end
159
+ end
160
+
161
+ private
162
+
163
+ def normalize_path(path)
164
+ p = path.to_s.gsub("\\", "/")
165
+ p = "/#{p}" unless p.start_with?("/")
166
+ p = p.chomp("/") unless p == "/"
167
+ p
168
+ end
169
+
170
+ def compile_pattern(path)
171
+ return Regexp.new("\\A/\\z") if path == "/"
172
+
173
+ parts = path.split("/").reject(&:empty?)
174
+ regex_parts = parts.map do |part|
175
+ if part =~ /\A\{(\w+)\}\z/
176
+ name = Regexp.last_match(1)
177
+ @param_names << { name: name.to_sym }
178
+ '([^/]+)'
179
+ else
180
+ Regexp.escape(part)
181
+ end
182
+ end
183
+ Regexp.new("\\A/#{regex_parts.join("/")}\\z")
184
+ end
185
+ end
186
+
108
187
  module Router
109
188
  class << self
110
189
  def routes
111
190
  @routes ||= []
112
191
  end
113
192
 
193
+ # Registered WebSocket routes
194
+ def ws_routes
195
+ @ws_routes ||= []
196
+ end
197
+
198
+ # Register a WebSocket route.
199
+ # The handler block receives (connection, event, data) where:
200
+ # connection — WebSocketConnection with #send, #broadcast, #close, #params
201
+ # event — :open, :message, or :close
202
+ # data — String payload for :message, nil for :open/:close
203
+ def websocket(path, &block)
204
+ ws_route = WebSocketRoute.new(path, block)
205
+ ws_routes << ws_route
206
+ Tina4::Log.debug("WebSocket route registered: #{path}")
207
+ ws_route
208
+ end
209
+
210
+ # Find a matching WebSocket route for a given path.
211
+ # Returns [ws_route, params] or nil.
212
+ def find_ws_route(path)
213
+ normalized = path.gsub("\\", "/")
214
+ normalized = "/#{normalized}" unless normalized.start_with?("/")
215
+ normalized = normalized.chomp("/") unless normalized == "/"
216
+
217
+ ws_routes.each do |ws_route|
218
+ params = ws_route.match?(normalized)
219
+ return [ws_route, params] if params
220
+ end
221
+ nil
222
+ end
223
+
114
224
  # Routes indexed by HTTP method for O(1) method lookup
115
225
  def method_index
116
226
  @method_index ||= Hash.new { |h, k| h[k] = [] }
@@ -169,9 +279,26 @@ module Tina4
169
279
  nil
170
280
  end
171
281
 
282
+ # Register a class-based middleware globally.
283
+ # The class should define static before_* and/or after_* methods.
284
+ # Example:
285
+ # class AuthMiddleware
286
+ # def self.before_auth(request, response)
287
+ # unless request.headers["authorization"]
288
+ # return [request, response.json({ error: "Unauthorized" }, 401)]
289
+ # end
290
+ # [request, response]
291
+ # end
292
+ # end
293
+ # Tina4::Router.use(AuthMiddleware)
294
+ def use(klass)
295
+ Tina4::Middleware.use(klass)
296
+ end
297
+
172
298
  def clear!
173
299
  @routes = []
174
300
  @method_index = Hash.new { |h, k| h[k] = [] }
301
+ @ws_routes = []
175
302
  end
176
303
 
177
304
  def group(prefix, auth_handler: nil, middleware: [], &block)
@@ -171,7 +171,7 @@ module Tina4
171
171
 
172
172
  def run_loop(name, handler, options, ctx)
173
173
  max_retries = options.fetch(:max_retries, 3)
174
- sleep_interval = (ENV["TINA4_SERVICE_SLEEP"] || 1).to_f
174
+ sleep_interval = (ENV["TINA4_SERVICE_SLEEP"] || 5).to_i.to_f
175
175
 
176
176
  if options[:daemon]
177
177
  run_daemon(name, handler, options, ctx, max_retries)
data/lib/tina4/session.rb CHANGED
@@ -108,7 +108,8 @@ module Tina4
108
108
  end
109
109
 
110
110
  def cookie_header
111
- "#{@options[:cookie_name]}=#{@id}; Path=/; HttpOnly; SameSite=Lax; Max-Age=#{@options[:max_age]}"
111
+ samesite = ENV["TINA4_SESSION_SAMESITE"] || "Lax"
112
+ "#{@options[:cookie_name]}=#{@id}; Path=/; HttpOnly; SameSite=#{samesite}; Max-Age=#{@options[:max_age]}"
112
113
  end
113
114
 
114
115
  private
@@ -135,6 +136,10 @@ module Tina4
135
136
  Tina4::SessionHandlers::RedisHandler.new(@options[:handler_options])
136
137
  when :mongo, :mongodb
137
138
  Tina4::SessionHandlers::MongoHandler.new(@options[:handler_options])
139
+ when :valkey
140
+ Tina4::SessionHandlers::ValkeyHandler.new(@options[:handler_options])
141
+ when :database, :db
142
+ Tina4::SessionHandlers::DatabaseHandler.new(@options[:handler_options])
138
143
  else
139
144
  Tina4::SessionHandlers::FileHandler.new(@options[:handler_options])
140
145
  end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Tina4
6
+ module SessionHandlers
7
+ class DatabaseHandler
8
+ TABLE_NAME = "tina4_session"
9
+
10
+ CREATE_TABLE_SQL = <<~SQL
11
+ CREATE TABLE IF NOT EXISTS #{TABLE_NAME} (
12
+ session_id VARCHAR(255) PRIMARY KEY,
13
+ data TEXT NOT NULL,
14
+ expires_at REAL NOT NULL
15
+ )
16
+ SQL
17
+
18
+ def initialize(options = {})
19
+ @ttl = options[:ttl] || 86400
20
+ @db = options[:db] || Tina4::Database.new(ENV["DATABASE_URL"])
21
+ ensure_table
22
+ end
23
+
24
+ def read(session_id)
25
+ row = @db.fetch_one("SELECT data, expires_at FROM #{TABLE_NAME} WHERE session_id = ?", [session_id])
26
+ return nil unless row
27
+
28
+ expires_at = row["expires_at"].to_f
29
+ if expires_at > 0 && expires_at < Time.now.to_f
30
+ destroy(session_id)
31
+ return nil
32
+ end
33
+
34
+ JSON.parse(row["data"])
35
+ rescue JSON::ParserError
36
+ nil
37
+ end
38
+
39
+ def write(session_id, data)
40
+ expires_at = @ttl > 0 ? Time.now.to_f + @ttl : 0.0
41
+ json_data = JSON.generate(data)
42
+
43
+ existing = @db.fetch_one("SELECT session_id FROM #{TABLE_NAME} WHERE session_id = ?", [session_id])
44
+ if existing
45
+ @db.execute("UPDATE #{TABLE_NAME} SET data = ?, expires_at = ? WHERE session_id = ?", [json_data, expires_at, session_id])
46
+ else
47
+ @db.execute("INSERT INTO #{TABLE_NAME} (session_id, data, expires_at) VALUES (?, ?, ?)", [session_id, json_data, expires_at])
48
+ end
49
+ end
50
+
51
+ def destroy(session_id)
52
+ @db.execute("DELETE FROM #{TABLE_NAME} WHERE session_id = ?", [session_id])
53
+ end
54
+
55
+ def cleanup
56
+ @db.execute("DELETE FROM #{TABLE_NAME} WHERE expires_at > 0 AND expires_at < ?", [Time.now.to_f])
57
+ end
58
+
59
+ private
60
+
61
+ def ensure_table
62
+ @db.execute(CREATE_TABLE_SQL)
63
+ end
64
+ end
65
+ end
66
+ end
data/lib/tina4/swagger.rb CHANGED
@@ -18,7 +18,7 @@ module Tina4
18
18
  {
19
19
  "openapi" => "3.0.3",
20
20
  "info" => {
21
- "title" => ENV["PROJECT_NAME"] || "Tina4 Ruby API",
21
+ "title" => ENV["SWAGGER_TITLE"] || ENV["PROJECT_NAME"] || "Tina4 API",
22
22
  "version" => ENV["VERSION"] || Tina4::VERSION,
23
23
  "description" => "Auto-generated API documentation"
24
24
  },
@@ -142,6 +142,14 @@ module Tina4
142
142
  @parent_template = nil
143
143
  end
144
144
 
145
+ # Reset context and blocks for reuse (avoids creating new instances in loops)
146
+ def reset_context(context)
147
+ @context = context
148
+ @blocks = {}
149
+ @parent_template = nil
150
+ self
151
+ end
152
+
145
153
  def render(content)
146
154
  content = process_extends(content)
147
155
  content = process_blocks(content)
@@ -226,6 +234,7 @@ module Tina4
226
234
  when Integer then (0...collection).to_a
227
235
  else []
228
236
  end
237
+ sub_engine = TwigEngine.new({}, @base_dir)
229
238
  items.each_with_index do |item, index|
230
239
  loop_context = @context.dup
231
240
  loop_context["loop"] = {
@@ -241,7 +250,7 @@ module Tina4
241
250
  else
242
251
  loop_context[key_or_val] = item
243
252
  end
244
- sub_engine = TwigEngine.new(loop_context, @base_dir)
253
+ sub_engine.reset_context(loop_context)
245
254
  output << sub_engine.render(body)
246
255
  end
247
256
  output