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.
- checksums.yaml +4 -4
- data/README.md +120 -32
- data/lib/tina4/auth.rb +137 -27
- data/lib/tina4/auto_crud.rb +55 -3
- data/lib/tina4/cli.rb +228 -28
- data/lib/tina4/cors.rb +1 -1
- data/lib/tina4/database.rb +230 -26
- data/lib/tina4/database_result.rb +122 -8
- data/lib/tina4/dev_mailbox.rb +1 -1
- data/lib/tina4/env.rb +1 -1
- data/lib/tina4/frond.rb +314 -7
- data/lib/tina4/gallery/queue/meta.json +1 -1
- data/lib/tina4/gallery/queue/src/routes/api/gallery_queue.rb +314 -16
- data/lib/tina4/localization.rb +1 -1
- data/lib/tina4/messenger.rb +111 -33
- data/lib/tina4/middleware.rb +349 -1
- data/lib/tina4/migration.rb +132 -11
- data/lib/tina4/orm.rb +149 -18
- 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 +219 -61
- 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 +200 -11
- data/lib/tina4/request.rb +14 -1
- data/lib/tina4/response.rb +26 -0
- data/lib/tina4/response_cache.rb +446 -29
- 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/templates/errors/404.twig +2 -2
- data/lib/tina4/templates/errors/500.twig +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 +126 -5
- metadata +40 -3
data/lib/tina4/middleware.rb
CHANGED
|
@@ -11,6 +11,11 @@ module Tina4
|
|
|
11
11
|
@after_handlers ||= []
|
|
12
12
|
end
|
|
13
13
|
|
|
14
|
+
# Registry of class-based middleware (registered via Router.use)
|
|
15
|
+
def global_middleware
|
|
16
|
+
@global_middleware ||= []
|
|
17
|
+
end
|
|
18
|
+
|
|
14
19
|
def before(pattern = nil, &block)
|
|
15
20
|
before_handlers << { pattern: pattern, handler: block }
|
|
16
21
|
end
|
|
@@ -19,26 +24,63 @@ module Tina4
|
|
|
19
24
|
after_handlers << { pattern: pattern, handler: block }
|
|
20
25
|
end
|
|
21
26
|
|
|
27
|
+
# Register a class-based middleware globally.
|
|
28
|
+
# The class should define static before_* and/or after_* methods.
|
|
29
|
+
def use(klass)
|
|
30
|
+
global_middleware << klass unless global_middleware.include?(klass)
|
|
31
|
+
end
|
|
32
|
+
|
|
22
33
|
def clear!
|
|
23
34
|
@before_handlers = []
|
|
24
35
|
@after_handlers = []
|
|
36
|
+
@global_middleware = []
|
|
25
37
|
end
|
|
26
38
|
|
|
39
|
+
# Run all "before" hooks: block-based handlers, then class-based before_* methods.
|
|
40
|
+
# Returns [request, response] on success, or false to halt the request.
|
|
27
41
|
def run_before(request, response)
|
|
42
|
+
# 1. Block-based before handlers (backward compat)
|
|
28
43
|
before_handlers.each do |entry|
|
|
29
44
|
next unless matches_pattern?(request.path, entry[:pattern])
|
|
30
45
|
result = entry[:handler].call(request, response)
|
|
31
|
-
# If handler returns false, halt the request
|
|
32
46
|
return false if result == false
|
|
33
47
|
end
|
|
48
|
+
|
|
49
|
+
# 2. Class-based middleware: call every before_* method
|
|
50
|
+
global_middleware.each do |klass|
|
|
51
|
+
before_methods_for(klass).each do |method_name|
|
|
52
|
+
result = klass.send(method_name, request, response)
|
|
53
|
+
# Support returning [request, response] (Python convention) or false to halt
|
|
54
|
+
if result == false
|
|
55
|
+
return false
|
|
56
|
+
elsif result.is_a?(Array) && result.length == 2
|
|
57
|
+
request, response = result
|
|
58
|
+
# If response already has a non-2xx status, halt processing
|
|
59
|
+
return false if response.status_code >= 400
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
34
64
|
true
|
|
35
65
|
end
|
|
36
66
|
|
|
67
|
+
# Run all "after" hooks: block-based handlers, then class-based after_* methods.
|
|
37
68
|
def run_after(request, response)
|
|
69
|
+
# 1. Block-based after handlers (backward compat)
|
|
38
70
|
after_handlers.each do |entry|
|
|
39
71
|
next unless matches_pattern?(request.path, entry[:pattern])
|
|
40
72
|
entry[:handler].call(request, response)
|
|
41
73
|
end
|
|
74
|
+
|
|
75
|
+
# 2. Class-based middleware: call every after_* method
|
|
76
|
+
global_middleware.each do |klass|
|
|
77
|
+
after_methods_for(klass).each do |method_name|
|
|
78
|
+
result = klass.send(method_name, request, response)
|
|
79
|
+
if result.is_a?(Array) && result.length == 2
|
|
80
|
+
request, response = result
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
42
84
|
end
|
|
43
85
|
|
|
44
86
|
private
|
|
@@ -54,6 +96,312 @@ module Tina4
|
|
|
54
96
|
true
|
|
55
97
|
end
|
|
56
98
|
end
|
|
99
|
+
|
|
100
|
+
# Collect all public class methods matching before_*
|
|
101
|
+
def before_methods_for(klass)
|
|
102
|
+
klass.methods(false).select { |m| m.to_s.start_with?("before_") }.sort
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Collect all public class methods matching after_*
|
|
106
|
+
def after_methods_for(klass)
|
|
107
|
+
klass.methods(false).select { |m| m.to_s.start_with?("after_") }.sort
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# ---------------------------------------------------------------------------
|
|
113
|
+
# Built-in class-based middleware
|
|
114
|
+
# ---------------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
# CorsClassMiddleware -- sets CORS headers from env vars on every response.
|
|
117
|
+
# Uses the same config source as CorsMiddleware module.
|
|
118
|
+
class CorsClassMiddleware
|
|
119
|
+
class << self
|
|
120
|
+
def before_cors(request, response)
|
|
121
|
+
config = load_config
|
|
122
|
+
origin = resolve_origin(request, config)
|
|
123
|
+
|
|
124
|
+
response.headers["access-control-allow-origin"] = origin
|
|
125
|
+
response.headers["access-control-allow-methods"] = config[:methods]
|
|
126
|
+
response.headers["access-control-allow-headers"] = config[:headers]
|
|
127
|
+
response.headers["access-control-max-age"] = config[:max_age]
|
|
128
|
+
if config[:credentials] == "true"
|
|
129
|
+
response.headers["access-control-allow-credentials"] = "true"
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
[request, response]
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
private
|
|
136
|
+
|
|
137
|
+
def load_config
|
|
138
|
+
{
|
|
139
|
+
origins: ENV["TINA4_CORS_ORIGINS"] || "*",
|
|
140
|
+
methods: ENV["TINA4_CORS_METHODS"] || "GET, POST, PUT, PATCH, DELETE, OPTIONS",
|
|
141
|
+
headers: ENV["TINA4_CORS_HEADERS"] || "Content-Type,Authorization,X-Request-ID",
|
|
142
|
+
max_age: ENV["TINA4_CORS_MAX_AGE"] || "86400",
|
|
143
|
+
credentials: ENV["TINA4_CORS_CREDENTIALS"] || "false"
|
|
144
|
+
}
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def resolve_origin(request, config)
|
|
148
|
+
request_origin = request.headers["origin"] || request.headers["referer"]
|
|
149
|
+
|
|
150
|
+
if config[:origins] == "*"
|
|
151
|
+
"*"
|
|
152
|
+
elsif request_origin
|
|
153
|
+
allowed = config[:origins].split(",").map(&:strip)
|
|
154
|
+
clean = request_origin.chomp("/")
|
|
155
|
+
allowed.include?(clean) ? clean : allowed.first || "*"
|
|
156
|
+
else
|
|
157
|
+
config[:origins].split(",").first&.strip || "*"
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# RateLimiterMiddleware -- tracks requests per IP, returns 429 when exceeded.
|
|
164
|
+
# Config via env: TINA4_RATE_LIMIT (default 100), TINA4_RATE_WINDOW (default 60s).
|
|
165
|
+
class RateLimiterMiddleware
|
|
166
|
+
@store = {}
|
|
167
|
+
@mutex = Mutex.new
|
|
168
|
+
@last_cleanup = Time.now
|
|
169
|
+
|
|
170
|
+
class << self
|
|
171
|
+
def before_rate_limit(request, response)
|
|
172
|
+
limit = (ENV["TINA4_RATE_LIMIT"] || 100).to_i
|
|
173
|
+
window = (ENV["TINA4_RATE_WINDOW"] || 60).to_i
|
|
174
|
+
ip = request.ip || "unknown"
|
|
175
|
+
now = Time.now
|
|
176
|
+
|
|
177
|
+
cleanup_if_needed(now, window)
|
|
178
|
+
|
|
179
|
+
@mutex.synchronize do
|
|
180
|
+
@store[ip] ||= []
|
|
181
|
+
entries = @store[ip]
|
|
182
|
+
|
|
183
|
+
# Sliding window -- drop expired timestamps
|
|
184
|
+
cutoff = now - window
|
|
185
|
+
entries.reject! { |t| t < cutoff }
|
|
186
|
+
|
|
187
|
+
if entries.length >= limit
|
|
188
|
+
oldest = entries.first
|
|
189
|
+
retry_after = [(oldest + window - now).ceil, 1].max
|
|
190
|
+
|
|
191
|
+
response.headers["X-RateLimit-Limit"] = limit.to_s
|
|
192
|
+
response.headers["X-RateLimit-Remaining"] = "0"
|
|
193
|
+
response.headers["X-RateLimit-Reset"] = (oldest + window).to_i.to_s
|
|
194
|
+
response.headers["Retry-After"] = retry_after.to_s
|
|
195
|
+
response.json({ error: "Too Many Requests", retry_after: retry_after }, 429)
|
|
196
|
+
|
|
197
|
+
return [request, response]
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
entries << now
|
|
201
|
+
|
|
202
|
+
response.headers["X-RateLimit-Limit"] = limit.to_s
|
|
203
|
+
response.headers["X-RateLimit-Remaining"] = (limit - entries.length).to_s
|
|
204
|
+
response.headers["X-RateLimit-Reset"] = (now + window).to_i.to_s
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
[request, response]
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Allow resetting state (useful in tests)
|
|
211
|
+
def reset!
|
|
212
|
+
@mutex.synchronize { @store.clear }
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
private
|
|
216
|
+
|
|
217
|
+
def cleanup_if_needed(now, window)
|
|
218
|
+
return if now - @last_cleanup < window
|
|
219
|
+
|
|
220
|
+
@mutex.synchronize do
|
|
221
|
+
return if now - @last_cleanup < window
|
|
222
|
+
|
|
223
|
+
cutoff = now - window
|
|
224
|
+
@store.delete_if do |_ip, entries|
|
|
225
|
+
entries.reject! { |t| t < cutoff }
|
|
226
|
+
entries.empty?
|
|
227
|
+
end
|
|
228
|
+
@last_cleanup = now
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# RequestLoggerMiddleware -- logs method, path, and elapsed time for every request.
|
|
235
|
+
class RequestLoggerMiddleware
|
|
236
|
+
@request_times = {}
|
|
237
|
+
@mutex = Mutex.new
|
|
238
|
+
|
|
239
|
+
class << self
|
|
240
|
+
def before_log(request, response)
|
|
241
|
+
request_key = "#{request.object_id}"
|
|
242
|
+
@mutex.synchronize do
|
|
243
|
+
@request_times[request_key] = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
244
|
+
end
|
|
245
|
+
[request, response]
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def after_log(request, response)
|
|
249
|
+
request_key = "#{request.object_id}"
|
|
250
|
+
start_time = @mutex.synchronize { @request_times.delete(request_key) }
|
|
251
|
+
|
|
252
|
+
if start_time
|
|
253
|
+
elapsed_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000).round(3)
|
|
254
|
+
else
|
|
255
|
+
elapsed_ms = 0.0
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
Tina4::Log.info("[RequestLogger] #{request.method} #{request.path} -> #{response.status_code} (#{elapsed_ms}ms)")
|
|
259
|
+
[request, response]
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def reset!
|
|
263
|
+
@mutex.synchronize { @request_times.clear }
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# CsrfMiddleware -- validates form tokens on state-changing requests.
|
|
269
|
+
#
|
|
270
|
+
# Off by default -- only active when TINA4_CSRF=true in .env or when
|
|
271
|
+
# registered explicitly via Router.use(CsrfMiddleware).
|
|
272
|
+
#
|
|
273
|
+
# Behaviour:
|
|
274
|
+
# - Skips GET, HEAD, OPTIONS requests.
|
|
275
|
+
# - Skips routes marked .no_auth.
|
|
276
|
+
# - Skips requests with a valid Authorization: Bearer header (API clients).
|
|
277
|
+
# - Checks request.body["formToken"] then request.headers["X-Form-Token"].
|
|
278
|
+
# - Rejects if token found in request.query["formToken"] (log warning, 403).
|
|
279
|
+
# - Validates token with Auth.valid_token using SECRET env var.
|
|
280
|
+
# - If token payload has session_id, verifies it matches request.session.session_id.
|
|
281
|
+
# - Returns 403 with response.json({error: "CSRF_INVALID", message: ...}, 403) on failure.
|
|
282
|
+
class CsrfMiddleware
|
|
283
|
+
class << self
|
|
284
|
+
def before_csrf(request, response)
|
|
285
|
+
# Allow disabling CSRF via env var
|
|
286
|
+
csrf_env = ENV["TINA4_CSRF"].to_s.downcase
|
|
287
|
+
return [request, response] if %w[false 0 no].include?(csrf_env)
|
|
288
|
+
|
|
289
|
+
# Skip safe HTTP methods
|
|
290
|
+
method = (request.method || "GET").upcase
|
|
291
|
+
return [request, response] if %w[GET HEAD OPTIONS].include?(method)
|
|
292
|
+
|
|
293
|
+
# Skip routes marked no_auth
|
|
294
|
+
handler = request.respond_to?(:handler) ? request.handler : nil
|
|
295
|
+
if handler
|
|
296
|
+
no_auth = if handler.is_a?(Hash)
|
|
297
|
+
handler[:no_auth] || handler[:noAuth]
|
|
298
|
+
elsif handler.respond_to?(:no_auth)
|
|
299
|
+
handler.no_auth
|
|
300
|
+
end
|
|
301
|
+
return [request, response] if no_auth
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
# Skip requests with valid Bearer token (API clients)
|
|
305
|
+
headers = request.respond_to?(:headers) ? request.headers : {}
|
|
306
|
+
auth_header = headers["authorization"] || headers["Authorization"] || ""
|
|
307
|
+
if auth_header.start_with?("Bearer ")
|
|
308
|
+
bearer_token = auth_header[7..].strip
|
|
309
|
+
unless bearer_token.empty?
|
|
310
|
+
payload = Tina4::Auth.valid_token(bearer_token)
|
|
311
|
+
return [request, response] if payload
|
|
312
|
+
end
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
# Reject if token is in query string (security risk)
|
|
316
|
+
query = if request.respond_to?(:params)
|
|
317
|
+
request.params
|
|
318
|
+
elsif request.respond_to?(:query)
|
|
319
|
+
request.query
|
|
320
|
+
else
|
|
321
|
+
{}
|
|
322
|
+
end
|
|
323
|
+
query ||= {}
|
|
324
|
+
|
|
325
|
+
if query.is_a?(Hash) && query["formToken"] && !query["formToken"].to_s.empty?
|
|
326
|
+
Tina4::Log.warning("[CSRF] Token found in query string — rejected for security")
|
|
327
|
+
response.json({ error: "CSRF_INVALID", message: "Form token must not be sent in the URL query string" }, 403)
|
|
328
|
+
return [request, response]
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
# Extract token: body first, then header
|
|
332
|
+
token = nil
|
|
333
|
+
body = request.respond_to?(:body) ? request.body : nil
|
|
334
|
+
body ||= {}
|
|
335
|
+
token = body["formToken"] if body.is_a?(Hash)
|
|
336
|
+
|
|
337
|
+
if token.nil? || token.to_s.empty?
|
|
338
|
+
token = headers["X-Form-Token"] || headers["x-form-token"] || ""
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
if token.nil? || token.to_s.empty?
|
|
342
|
+
response.json({ error: "CSRF_INVALID", message: "Invalid or missing form token" }, 403)
|
|
343
|
+
return [request, response]
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
# Validate the token
|
|
347
|
+
payload = Tina4::Auth.valid_token(token.to_s)
|
|
348
|
+
|
|
349
|
+
if payload.nil?
|
|
350
|
+
response.json({ error: "CSRF_INVALID", message: "Invalid or missing form token" }, 403)
|
|
351
|
+
return [request, response]
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
# Session binding — if token has session_id, verify it matches
|
|
355
|
+
token_session_id = payload["session_id"]
|
|
356
|
+
if token_session_id
|
|
357
|
+
current_session_id = nil
|
|
358
|
+
session = request.respond_to?(:session) ? request.session : nil
|
|
359
|
+
if session
|
|
360
|
+
current_session_id = if session.respond_to?(:session_id)
|
|
361
|
+
session.session_id
|
|
362
|
+
elsif session.is_a?(Hash)
|
|
363
|
+
session["session_id"]
|
|
364
|
+
elsif session.respond_to?(:get)
|
|
365
|
+
session.get("session_id")
|
|
366
|
+
end
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
if current_session_id && token_session_id != current_session_id
|
|
370
|
+
response.json({ error: "CSRF_INVALID", message: "Invalid or missing form token" }, 403)
|
|
371
|
+
return [request, response]
|
|
372
|
+
end
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
[request, response]
|
|
376
|
+
end
|
|
377
|
+
end
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
# SecurityHeadersMiddleware -- injects security headers on every response.
|
|
381
|
+
# Config via env:
|
|
382
|
+
# TINA4_FRAME_OPTIONS — X-Frame-Options (default: SAMEORIGIN)
|
|
383
|
+
# TINA4_HSTS — Strict-Transport-Security max-age (default: "" = off)
|
|
384
|
+
# TINA4_CSP — Content-Security-Policy (default: "default-src 'self'")
|
|
385
|
+
# TINA4_REFERRER_POLICY — Referrer-Policy (default: strict-origin-when-cross-origin)
|
|
386
|
+
# TINA4_PERMISSIONS_POLICY — Permissions-Policy (default: camera=(), microphone=(), geolocation=())
|
|
387
|
+
class SecurityHeadersMiddleware
|
|
388
|
+
class << self
|
|
389
|
+
def before_security(request, response)
|
|
390
|
+
response.headers["X-Frame-Options"] = ENV["TINA4_FRAME_OPTIONS"] || "SAMEORIGIN"
|
|
391
|
+
response.headers["X-Content-Type-Options"] = "nosniff"
|
|
392
|
+
|
|
393
|
+
hsts = ENV["TINA4_HSTS"] || ""
|
|
394
|
+
unless hsts.empty?
|
|
395
|
+
response.headers["Strict-Transport-Security"] = "max-age=#{hsts}; includeSubDomains"
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
response.headers["Content-Security-Policy"] = ENV["TINA4_CSP"] || "default-src 'self'"
|
|
399
|
+
response.headers["Referrer-Policy"] = ENV["TINA4_REFERRER_POLICY"] || "strict-origin-when-cross-origin"
|
|
400
|
+
response.headers["X-XSS-Protection"] = "0"
|
|
401
|
+
response.headers["Permissions-Policy"] = ENV["TINA4_PERMISSIONS_POLICY"] || "camera=(), microphone=(), geolocation=()"
|
|
402
|
+
|
|
403
|
+
[request, response]
|
|
404
|
+
end
|
|
57
405
|
end
|
|
58
406
|
end
|
|
59
407
|
end
|
data/lib/tina4/migration.rb
CHANGED
|
@@ -9,7 +9,7 @@ module Tina4
|
|
|
9
9
|
|
|
10
10
|
def initialize(db, migrations_dir: nil)
|
|
11
11
|
@db = db
|
|
12
|
-
@migrations_dir = migrations_dir ||
|
|
12
|
+
@migrations_dir = migrations_dir || resolve_migrations_dir
|
|
13
13
|
ensure_tracking_table
|
|
14
14
|
end
|
|
15
15
|
|
|
@@ -89,14 +89,28 @@ module Tina4
|
|
|
89
89
|
|
|
90
90
|
private
|
|
91
91
|
|
|
92
|
+
# Resolve migrations directory: prefer src/migrations, fall back to migrations/
|
|
93
|
+
def resolve_migrations_dir
|
|
94
|
+
src_dir = File.join(Dir.pwd, "src", "migrations")
|
|
95
|
+
return src_dir if Dir.exist?(src_dir)
|
|
96
|
+
|
|
97
|
+
root_dir = File.join(Dir.pwd, "migrations")
|
|
98
|
+
return root_dir if Dir.exist?(root_dir)
|
|
99
|
+
|
|
100
|
+
# Default to src/migrations (will be created when needed)
|
|
101
|
+
src_dir
|
|
102
|
+
end
|
|
103
|
+
|
|
92
104
|
def ensure_tracking_table
|
|
93
105
|
unless @db.table_exists?(TRACKING_TABLE)
|
|
94
106
|
@db.execute(<<~SQL)
|
|
95
107
|
CREATE TABLE #{TRACKING_TABLE} (
|
|
96
108
|
id INTEGER PRIMARY KEY,
|
|
97
109
|
migration_name VARCHAR(255) NOT NULL,
|
|
110
|
+
description VARCHAR(255) DEFAULT '',
|
|
98
111
|
batch INTEGER NOT NULL DEFAULT 1,
|
|
99
|
-
executed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
112
|
+
executed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
113
|
+
passed INTEGER NOT NULL DEFAULT 1
|
|
100
114
|
)
|
|
101
115
|
SQL
|
|
102
116
|
Tina4::Log.info("Created migrations tracking table")
|
|
@@ -104,17 +118,17 @@ module Tina4
|
|
|
104
118
|
end
|
|
105
119
|
|
|
106
120
|
def completed_migrations
|
|
107
|
-
result = @db.fetch("SELECT migration_name FROM #{TRACKING_TABLE} ORDER BY id")
|
|
121
|
+
result = @db.fetch("SELECT migration_name FROM #{TRACKING_TABLE} WHERE passed = 1 ORDER BY id")
|
|
108
122
|
result.map { |r| r[:migration_name] }
|
|
109
123
|
end
|
|
110
124
|
|
|
111
125
|
def completed_migrations_with_batch
|
|
112
|
-
result = @db.fetch("SELECT id, migration_name, batch FROM #{TRACKING_TABLE} ORDER BY id")
|
|
126
|
+
result = @db.fetch("SELECT id, migration_name, batch FROM #{TRACKING_TABLE} WHERE passed = 1 ORDER BY id")
|
|
113
127
|
result.map { |r| { id: r[:id], migration_name: r[:migration_name], batch: r[:batch] } }
|
|
114
128
|
end
|
|
115
129
|
|
|
116
130
|
def next_batch_number
|
|
117
|
-
result = @db.fetch_one("SELECT MAX(batch) as max_batch FROM #{TRACKING_TABLE}")
|
|
131
|
+
result = @db.fetch_one("SELECT MAX(batch) as max_batch FROM #{TRACKING_TABLE} WHERE passed = 1")
|
|
118
132
|
(result && result[:max_batch] ? result[:max_batch].to_i : 0) + 1
|
|
119
133
|
end
|
|
120
134
|
|
|
@@ -123,12 +137,24 @@ module Tina4
|
|
|
123
137
|
|
|
124
138
|
completed = completed_migrations
|
|
125
139
|
# Support both .rb and .sql migration files
|
|
140
|
+
# Accept both 000001_name.sql (sequential) and YYYYMMDDHHMMSS_name.sql (timestamp) patterns
|
|
126
141
|
Dir.glob(File.join(@migrations_dir, "*.{rb,sql}"))
|
|
127
142
|
.reject { |f| f.end_with?(".down.sql") }
|
|
128
|
-
.
|
|
143
|
+
.sort_by { |f| migration_sort_key(File.basename(f)) }
|
|
129
144
|
.reject { |f| completed.include?(File.basename(f)) }
|
|
130
145
|
end
|
|
131
146
|
|
|
147
|
+
# Sort key that handles both 000001_name.sql and 20240315120000_name.sql patterns.
|
|
148
|
+
# Both are zero-padded numeric prefixes so alphabetical sorting works, but we
|
|
149
|
+
# extract the prefix explicitly to guarantee correct ordering when mixed.
|
|
150
|
+
def migration_sort_key(filename)
|
|
151
|
+
if filename =~ /\A(\d+)/
|
|
152
|
+
[$1.to_i, filename]
|
|
153
|
+
else
|
|
154
|
+
[0, filename]
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
132
158
|
def run_migration(file, batch)
|
|
133
159
|
name = File.basename(file)
|
|
134
160
|
Tina4::Log.info("Running migration: #{name}")
|
|
@@ -138,10 +164,11 @@ module Tina4
|
|
|
138
164
|
else
|
|
139
165
|
execute_sql_file(file)
|
|
140
166
|
end
|
|
141
|
-
record_migration(name, batch)
|
|
167
|
+
record_migration(name, batch, passed: 1)
|
|
142
168
|
{ name: name, status: "success" }
|
|
143
169
|
rescue => e
|
|
144
170
|
Tina4::Log.error("Migration failed: #{name} - #{e.message}")
|
|
171
|
+
record_migration(name, batch, passed: 0)
|
|
145
172
|
{ name: name, status: "failed", error: e.message }
|
|
146
173
|
end
|
|
147
174
|
end
|
|
@@ -181,17 +208,111 @@ module Tina4
|
|
|
181
208
|
migration.__send__(direction, @db)
|
|
182
209
|
end
|
|
183
210
|
|
|
211
|
+
# Split SQL into individual statements, handling:
|
|
212
|
+
# - $$ delimited stored procedure blocks
|
|
213
|
+
# - // delimited blocks
|
|
214
|
+
# - Block comments /* ... */
|
|
215
|
+
# - Line comments -- ...
|
|
216
|
+
# Matches the Python/Node.js approach: extract blocks first, split on ;, restore blocks.
|
|
217
|
+
def split_sql_statements(sql, delimiter = ";")
|
|
218
|
+
blocks = []
|
|
219
|
+
|
|
220
|
+
# Extract $$ ... $$ blocks (stored procedures, triggers, etc.)
|
|
221
|
+
processed = sql.gsub(/\$\$(.*?)\$\$/m) do
|
|
222
|
+
blocks << $~.to_s
|
|
223
|
+
"__BLOCK_#{blocks.length - 1}__"
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# Extract // ... // blocks
|
|
227
|
+
processed = processed.gsub(/\/\/(.*?)\/\//m) do
|
|
228
|
+
blocks << $~.to_s
|
|
229
|
+
"__BLOCK_#{blocks.length - 1}__"
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# Remove block comments (/* ... */) but not inside stored proc blocks (already extracted)
|
|
233
|
+
clean = processed.gsub(/\/\*.*?\*\//m, "")
|
|
234
|
+
|
|
235
|
+
statements = []
|
|
236
|
+
clean.split(delimiter).each do |stmt|
|
|
237
|
+
lines = []
|
|
238
|
+
stmt.split("\n").each do |line|
|
|
239
|
+
stripped = line.strip
|
|
240
|
+
next if stripped.empty? || stripped.start_with?("--")
|
|
241
|
+
# Remove inline comments (-- after SQL)
|
|
242
|
+
comment_pos = line.index("--")
|
|
243
|
+
line = line[0...comment_pos] if comment_pos && comment_pos >= 0
|
|
244
|
+
lines << line
|
|
245
|
+
end
|
|
246
|
+
cleaned = lines.join("\n").strip
|
|
247
|
+
|
|
248
|
+
# Restore block placeholders
|
|
249
|
+
blocks.each_with_index do |block, i|
|
|
250
|
+
cleaned = cleaned.gsub("__BLOCK_#{i}__", block)
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
statements << cleaned unless cleaned.empty?
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
statements
|
|
257
|
+
end
|
|
258
|
+
|
|
184
259
|
def execute_sql_file(file)
|
|
185
260
|
sql = File.read(file)
|
|
186
|
-
statements = sql
|
|
261
|
+
statements = split_sql_statements(sql)
|
|
187
262
|
statements.each do |stmt|
|
|
188
|
-
|
|
263
|
+
# Firebird lacks IF NOT EXISTS for ALTER TABLE ADD.
|
|
264
|
+
# Pre-check the system catalogue so duplicate columns are
|
|
265
|
+
# silently skipped instead of raising an error.
|
|
266
|
+
skip_reason = should_skip_for_firebird(stmt)
|
|
267
|
+
if skip_reason
|
|
268
|
+
Tina4::Log.info("Migration #{File.basename(file)}: #{skip_reason}")
|
|
269
|
+
next
|
|
270
|
+
end
|
|
189
271
|
@db.execute(stmt)
|
|
190
272
|
end
|
|
191
273
|
end
|
|
192
274
|
|
|
193
|
-
|
|
194
|
-
|
|
275
|
+
# Regex to match ALTER TABLE <table> ADD <column> ...
|
|
276
|
+
ALTER_ADD_RE = /\A\s*ALTER\s+TABLE\s+(?:"([^"]+)"|(\S+))\s+ADD\s+(?:"([^"]+)"|(\S+))/i
|
|
277
|
+
|
|
278
|
+
def firebird?
|
|
279
|
+
@db.driver_name == "firebird"
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
# Check if a column already exists in a Firebird table via RDB$RELATION_FIELDS.
|
|
283
|
+
# Firebird stores unquoted identifiers in upper-case.
|
|
284
|
+
def firebird_column_exists?(table, column)
|
|
285
|
+
row = @db.fetch_one(
|
|
286
|
+
"SELECT 1 FROM RDB\$RELATION_FIELDS WHERE RDB\$RELATION_NAME = ? AND TRIM(RDB\$FIELD_NAME) = ?",
|
|
287
|
+
[table.upcase, column.upcase]
|
|
288
|
+
)
|
|
289
|
+
!row.nil?
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
# If stmt is an ALTER TABLE ... ADD on Firebird and the column already exists,
|
|
293
|
+
# returns a skip reason. Returns nil if the statement should execute normally.
|
|
294
|
+
def should_skip_for_firebird(stmt)
|
|
295
|
+
return nil unless firebird?
|
|
296
|
+
|
|
297
|
+
m = stmt.match(ALTER_ADD_RE)
|
|
298
|
+
return nil unless m
|
|
299
|
+
|
|
300
|
+
table = m[1] || m[2]
|
|
301
|
+
column = m[3] || m[4]
|
|
302
|
+
|
|
303
|
+
if firebird_column_exists?(table, column)
|
|
304
|
+
"Column #{column} already exists in #{table}, skipping"
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
def record_migration(name, batch, passed: 1)
|
|
309
|
+
# Extract description from filename (strip numeric prefix and extension)
|
|
310
|
+
stem = File.basename(name, File.extname(name))
|
|
311
|
+
desc = stem.sub(/\A\d+_/, "").tr("_", " ")
|
|
312
|
+
@db.execute(
|
|
313
|
+
"INSERT INTO #{TRACKING_TABLE} (migration_name, description, batch, passed) VALUES (?, ?, ?, ?)",
|
|
314
|
+
[name, desc, batch, passed]
|
|
315
|
+
)
|
|
195
316
|
end
|
|
196
317
|
|
|
197
318
|
def remove_migration_record(name)
|