tina4ruby 3.13.98 → 3.13.99
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/CHANGELOG.md +84 -0
- data/lib/tina4/ai.rb +32 -3
- data/lib/tina4/api.rb +5 -0
- data/lib/tina4/auto_crud.rb +62 -4
- data/lib/tina4/background.rb +112 -31
- data/lib/tina4/cache.rb +3 -2
- data/lib/tina4/cli.rb +55 -67
- data/lib/tina4/database.rb +97 -49
- data/lib/tina4/database_adapter.rb +169 -15
- data/lib/tina4/dev_admin.rb +137 -9
- data/lib/tina4/dispatch_pipeline.rb +145 -4
- data/lib/tina4/drivers/firebird_driver.rb +59 -12
- data/lib/tina4/drivers/mongodb_driver.rb +98 -14
- data/lib/tina4/drivers/mssql_driver.rb +39 -2
- data/lib/tina4/drivers/mysql_driver.rb +43 -3
- data/lib/tina4/drivers/odbc_driver.rb +36 -2
- data/lib/tina4/drivers/postgres_driver.rb +5 -0
- data/lib/tina4/drivers/sqlite_driver.rb +11 -1
- data/lib/tina4/env.rb +1 -1
- data/lib/tina4/error_overlay.rb +43 -49
- data/lib/tina4/field_types.rb +33 -16
- data/lib/tina4/frond.rb +24 -2
- data/lib/tina4/gallery/auth/src/routes/api/gallery_auth.rb +1 -1
- data/lib/tina4/gallery/templates/src/templates/gallery_page.twig +1 -1
- data/lib/tina4/graphql.rb +2 -2
- data/lib/tina4/log.rb +652 -485
- data/lib/tina4/mcp.rb +9 -1
- data/lib/tina4/messenger.rb +25 -0
- data/lib/tina4/middleware.rb +189 -76
- data/lib/tina4/migration.rb +47 -15
- data/lib/tina4/orm.rb +280 -59
- data/lib/tina4/port_takeover.rb +202 -0
- data/lib/tina4/public/js/tina4-dev-admin.min.js +23 -19
- data/lib/tina4/rack_app.rb +201 -59
- data/lib/tina4/realtime.rb +6 -1
- data/lib/tina4/request.rb +259 -51
- data/lib/tina4/router.rb +20 -2
- data/lib/tina4/seeder.rb +68 -19
- data/lib/tina4/shutdown.rb +4 -0
- data/lib/tina4/sql_translator.rb +115 -86
- data/lib/tina4/swagger.rb +19 -3
- data/lib/tina4/template.rb +61 -6
- data/lib/tina4/test_client.rb +49 -3
- data/lib/tina4/testing.rb +16 -11
- data/lib/tina4/validator.rb +7 -1
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4/webserver.rb +28 -40
- data/lib/tina4.rb +12 -1
- metadata +3 -2
data/lib/tina4/mcp.rb
CHANGED
|
@@ -590,7 +590,15 @@ module Tina4
|
|
|
590
590
|
# share one fully-populated tool registry.
|
|
591
591
|
def self._default_mcp_server
|
|
592
592
|
@_default_mcp_server ||= begin
|
|
593
|
-
|
|
593
|
+
# VERSION-DEC-01 (feature 130): the built-in dev server's serverInfo
|
|
594
|
+
# must report the SAME version every other surface does (health,
|
|
595
|
+
# banner, dashboard), not the constructor's generic "1.0.0" default --
|
|
596
|
+
# found by direct verification against this live source, not assumed
|
|
597
|
+
# correct from the "Ruby is the reference" default. A user's OWN custom
|
|
598
|
+
# McpServer.new(path, name: ...) (no version: kwarg) is unaffected --
|
|
599
|
+
# that default stays "1.0.0" for app authors who have not set their own
|
|
600
|
+
# tool-server version.
|
|
601
|
+
server = McpServer.new("/__dev/mcp", name: "Tina4 Dev Tools", version: Tina4::VERSION)
|
|
594
602
|
McpDevTools.register(server)
|
|
595
603
|
server
|
|
596
604
|
end
|
data/lib/tina4/messenger.rb
CHANGED
|
@@ -193,6 +193,22 @@ module Tina4
|
|
|
193
193
|
)
|
|
194
194
|
end
|
|
195
195
|
|
|
196
|
+
# TINA4_MAIL_REDIRECT_TO (MAIL-DEC-01): the REAL-SEND path only --
|
|
197
|
+
# capture already returned above, so this never touches the capture
|
|
198
|
+
# branch. When the list is non-empty, replace every recipient with the
|
|
199
|
+
# redirect list (so ONLY the dev list receives the mail, never the real
|
|
200
|
+
# recipients) and preserve the originals in a header. Subject/body/
|
|
201
|
+
# attachments are untouched; #send's return shape is unchanged. Read
|
|
202
|
+
# fresh per call, matching should_capture?'s own env-read style.
|
|
203
|
+
redirect_to = parse_mail_redirect_list(ENV["TINA4_MAIL_REDIRECT_TO"])
|
|
204
|
+
unless redirect_to.empty?
|
|
205
|
+
original_to = (normalize_recipients(to) + normalize_recipients(cc) + normalize_recipients(bcc)).join(", ")
|
|
206
|
+
to = redirect_to
|
|
207
|
+
cc = []
|
|
208
|
+
bcc = []
|
|
209
|
+
headers = (headers || {}).merge("X-Tina4-Original-To" => original_to)
|
|
210
|
+
end
|
|
211
|
+
|
|
196
212
|
message_id = "<#{SecureRandom.uuid}@#{@host}>"
|
|
197
213
|
raw = build_message(
|
|
198
214
|
to: to, subject: subject, body: body, html: html, text: text,
|
|
@@ -476,6 +492,15 @@ module Tina4
|
|
|
476
492
|
end
|
|
477
493
|
end
|
|
478
494
|
|
|
495
|
+
# Parse TINA4_MAIL_REDIRECT_TO (MAIL-DEC-01): comma-separated addresses,
|
|
496
|
+
# each trimmed, blanks dropped. Empty/unset -> [] (redirect off, no
|
|
497
|
+
# behaviour change).
|
|
498
|
+
def parse_mail_redirect_list(raw)
|
|
499
|
+
return [] if raw.nil? || raw.empty?
|
|
500
|
+
|
|
501
|
+
raw.split(",").map(&:strip).reject(&:empty?)
|
|
502
|
+
end
|
|
503
|
+
|
|
479
504
|
def format_address(address, name = nil)
|
|
480
505
|
if name && !name.empty?
|
|
481
506
|
"#{name} <#{address}>"
|
data/lib/tina4/middleware.rb
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "cgi"
|
|
4
|
+
|
|
3
5
|
module Tina4
|
|
4
6
|
class Middleware
|
|
5
7
|
class << self
|
|
@@ -218,8 +220,8 @@ module Tina4
|
|
|
218
220
|
# per-route "filter" middleware (a 2-arg callable returning false, see
|
|
219
221
|
# Tina4::Route#run_middleware) must obey the SAME row as a before_* hook,
|
|
220
222
|
# and the rule should exist exactly once.
|
|
221
|
-
def refuse(response)
|
|
222
|
-
forbid(response) if default_response?(response)
|
|
223
|
+
def refuse(request, response)
|
|
224
|
+
forbid(request, response) if default_response?(response)
|
|
223
225
|
response
|
|
224
226
|
end
|
|
225
227
|
|
|
@@ -263,7 +265,7 @@ module Tina4
|
|
|
263
265
|
elsif result.is_a?(Array) && result.length == 2
|
|
264
266
|
request, response = result
|
|
265
267
|
elsif result == false
|
|
266
|
-
refuse(response)
|
|
268
|
+
refuse(request, response)
|
|
267
269
|
return [true, request, response]
|
|
268
270
|
end
|
|
269
271
|
|
|
@@ -294,16 +296,47 @@ module Tina4
|
|
|
294
296
|
body.to_s.empty?
|
|
295
297
|
end
|
|
296
298
|
|
|
297
|
-
# The canonical refusal
|
|
298
|
-
#
|
|
299
|
-
#
|
|
300
|
-
|
|
299
|
+
# The canonical refusal (ERR-DEC-01/ERR-DEC-02): routed through the SAME
|
|
300
|
+
# negotiated renderer 404/500 use, so a middleware refusal looks like
|
|
301
|
+
# every other error page - a user template if the app ships one, the
|
|
302
|
+
# framework's 403.twig otherwise, negotiated JSON for an API client -
|
|
303
|
+
# instead of the old bare/un-negotiated `{"error":"Forbidden","status":403}`.
|
|
304
|
+
def forbid(request, response)
|
|
305
|
+
request_id = Tina4::Log.get_request_id || ""
|
|
306
|
+
accept = request.respond_to?(:headers) ? (request.headers["accept"] || "") : ""
|
|
307
|
+
|
|
308
|
+
if Tina4::Template.wants_json?(accept)
|
|
309
|
+
set_response_json(response, error_json_body(request_id))
|
|
310
|
+
return response
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
path = request.respond_to?(:path) ? request.path.to_s : ""
|
|
314
|
+
html = begin
|
|
315
|
+
Tina4::Template.render_error(403, { "path" => CGI.escapeHTML(path), "request_id" => request_id })
|
|
316
|
+
rescue StandardError
|
|
317
|
+
nil
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
if html && response.respond_to?(:html)
|
|
321
|
+
response.html(html, 403)
|
|
322
|
+
else
|
|
323
|
+
set_response_json(response, error_json_body(request_id))
|
|
324
|
+
end
|
|
325
|
+
response
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
# The canonical JSON error envelope (ERR-DEC-02) - the SAME shape
|
|
329
|
+
# Python/PHP/Node build: {error: true, code, message, status, request_id}.
|
|
330
|
+
def error_json_body(request_id)
|
|
331
|
+
{ error: true, code: "FORBIDDEN", message: "Forbidden", status: 403, request_id: request_id }
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
def set_response_json(response, body)
|
|
301
335
|
if response.respond_to?(:json)
|
|
302
|
-
response.json(
|
|
336
|
+
response.json(body, 403)
|
|
303
337
|
elsif response.respond_to?(:status_code=)
|
|
304
338
|
response.status_code = 403
|
|
305
339
|
end
|
|
306
|
-
response
|
|
307
340
|
end
|
|
308
341
|
|
|
309
342
|
# Copy a response's state onto the object the CALLER still holds.
|
|
@@ -567,111 +600,171 @@ module Tina4
|
|
|
567
600
|
|
|
568
601
|
# CsrfMiddleware -- validates form tokens on state-changing requests.
|
|
569
602
|
#
|
|
570
|
-
#
|
|
571
|
-
#
|
|
603
|
+
# OFF by default: the middleware is NOT attached unless TINA4_CSRF is truthy
|
|
604
|
+
# (true/1/yes/on -- see .attach_from_env, called once at boot in
|
|
605
|
+
# Tina4.initialize!) OR it is registered explicitly via
|
|
606
|
+
# Router.use(CsrfMiddleware). Once attached, TINA4_CSRF=false (or 0/no) is the
|
|
607
|
+
# kill switch that disables enforcement again.
|
|
572
608
|
#
|
|
573
|
-
# Behaviour:
|
|
574
|
-
# - Skips GET, HEAD, OPTIONS
|
|
575
|
-
# - Skips
|
|
576
|
-
#
|
|
577
|
-
#
|
|
578
|
-
#
|
|
579
|
-
# -
|
|
580
|
-
#
|
|
581
|
-
#
|
|
609
|
+
# Behaviour (identical to the Python master's CsrfMiddleware.before_csrf):
|
|
610
|
+
# - Skips GET, HEAD, OPTIONS (safe methods).
|
|
611
|
+
# - Skips a public write route (.no_auth / auth: false -- auth_required
|
|
612
|
+
# false). The matched Route is attached to the request before this
|
|
613
|
+
# post-match middleware runs, so a genuinely public endpoint (login,
|
|
614
|
+
# webhook) is not gated.
|
|
615
|
+
# - Fails CLOSED: with TINA4_SECRET unset the signing secret resolves to
|
|
616
|
+
# blank (there is NO built-in default), and a blank HMAC key is publicly
|
|
617
|
+
# reproducible -- so no token can be trusted and every write is rejected
|
|
618
|
+
# (403). This is the SEC-01 no-default-secret guarantee.
|
|
619
|
+
# - Skips a request carrying a valid Authorization: Bearer token (API clients).
|
|
620
|
+
# - Reads request.body["formToken"] then the X-Form-Token header.
|
|
621
|
+
# - Rejects a token sent in the query string (403 + a logged warning) -- a
|
|
622
|
+
# URL leaks through logs, referers and history.
|
|
623
|
+
# - Validates the token with Auth.valid_token using the resolved secret, and
|
|
624
|
+
# enforces that the token's "type" claim is "form" -- a non-form JWT in the
|
|
625
|
+
# formToken slot is rejected even when its signature verifies.
|
|
626
|
+
# - If the token carries a session_id, it must match the request session's id.
|
|
627
|
+
# - Every rejection is HTTP 403 with the CSRF_INVALID envelope
|
|
628
|
+
# ({error:true, code:"CSRF_INVALID", message:, status:403}) via
|
|
629
|
+
# response.error -- byte-identical to Python/PHP/Node.
|
|
582
630
|
class CsrfMiddleware
|
|
583
631
|
class << self
|
|
584
632
|
def before_csrf(request, response)
|
|
585
|
-
#
|
|
586
|
-
|
|
633
|
+
# 1. Kill switch -- TINA4_CSRF in {false,0,no} disables all CSRF checks,
|
|
634
|
+
# even when the middleware is attached explicitly. Unset = enforced.
|
|
635
|
+
csrf_env = ENV["TINA4_CSRF"].to_s.strip.downcase
|
|
587
636
|
return [request, response] if %w[false 0 no].include?(csrf_env)
|
|
588
637
|
|
|
589
|
-
#
|
|
638
|
+
# 2. Safe HTTP methods never change state -- skip.
|
|
590
639
|
method = (request.method || "GET").upcase
|
|
591
640
|
return [request, response] if %w[GET HEAD OPTIONS].include?(method)
|
|
592
641
|
|
|
593
|
-
#
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
642
|
+
# 3. Public write routes (.no_auth / auth: false) skip CSRF -- a
|
|
643
|
+
# genuinely public endpoint has no session to protect. The matched
|
|
644
|
+
# Route is attached to the request before this post-match middleware
|
|
645
|
+
# runs (DispatchPipeline#prepare_route_request); a public write route
|
|
646
|
+
# has auth_required == false -- the SAME signal the auth gate reads.
|
|
647
|
+
# (Reading request.handler, as this once did, was dead code: the live
|
|
648
|
+
# request never carries a handler, so the no_auth skip never fired.)
|
|
649
|
+
route = request.respond_to?(:route) ? request.route : nil
|
|
650
|
+
if route.respond_to?(:auth_required) && route.auth_required == false
|
|
651
|
+
return [request, response]
|
|
652
|
+
end
|
|
653
|
+
|
|
654
|
+
# 4/5. Resolve the signing secret ONCE, fail-closed. TINA4_SECRET unset
|
|
655
|
+
# resolves to blank (there is NO built-in default); a blank HMAC key
|
|
656
|
+
# is publicly reproducible, so a token signed with it -- or with the
|
|
657
|
+
# retired public 'tina4-default-secret' -- is a forgery. Reject every
|
|
658
|
+
# write rather than validate against a guessable key. SEC-01.
|
|
659
|
+
secret = Tina4::Auth.hmac_secret
|
|
660
|
+
if secret.to_s.empty?
|
|
661
|
+
return [request, response.error(
|
|
662
|
+
"CSRF_INVALID",
|
|
663
|
+
"CSRF token cannot be validated: TINA4_SECRET is not set",
|
|
664
|
+
403
|
|
665
|
+
)]
|
|
602
666
|
end
|
|
603
667
|
|
|
604
|
-
#
|
|
668
|
+
# 6. A valid Bearer JWT means an API client authenticating per request --
|
|
669
|
+
# not subject to the cookie-replay attack CSRF defends against.
|
|
605
670
|
headers = request.respond_to?(:headers) ? request.headers : {}
|
|
606
|
-
auth_header = headers["authorization"] || headers["Authorization"] || ""
|
|
671
|
+
auth_header = (headers["authorization"] || headers["Authorization"] || "").to_s
|
|
607
672
|
if auth_header.start_with?("Bearer ")
|
|
608
|
-
bearer_token = auth_header[7..].strip
|
|
609
|
-
|
|
610
|
-
return [request, response] if Tina4::Auth.valid_token(bearer_token)
|
|
611
|
-
end
|
|
673
|
+
bearer_token = auth_header[7..].to_s.strip
|
|
674
|
+
return [request, response] if !bearer_token.empty? && Tina4::Auth.valid_token(bearer_token)
|
|
612
675
|
end
|
|
613
676
|
|
|
614
|
-
#
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
{}
|
|
621
|
-
end
|
|
622
|
-
query ||= {}
|
|
623
|
-
|
|
624
|
-
if query.is_a?(Hash) && query["formToken"] && !query["formToken"].to_s.empty?
|
|
677
|
+
# 7. A token in the query string leaks through logs/referers/history --
|
|
678
|
+
# reject it. Read the QUERY STRING only, never request.params, which
|
|
679
|
+
# merges the body (a legit body token would false-trip this check).
|
|
680
|
+
query = request.respond_to?(:query) ? request.query : {}
|
|
681
|
+
query = {} unless query.is_a?(Hash)
|
|
682
|
+
if !query["formToken"].to_s.empty?
|
|
625
683
|
Tina4::Log.warning("[CSRF] Token found in query string — rejected for security")
|
|
626
|
-
response.
|
|
627
|
-
|
|
684
|
+
return [request, response.error(
|
|
685
|
+
"CSRF_INVALID",
|
|
686
|
+
"Form token must not be sent in the URL query string",
|
|
687
|
+
403
|
|
688
|
+
)]
|
|
628
689
|
end
|
|
629
690
|
|
|
630
|
-
# Extract token: body first, then header
|
|
691
|
+
# 8. Extract the token: body first, then the X-Form-Token header.
|
|
631
692
|
token = nil
|
|
632
693
|
body = request.respond_to?(:body) ? request.body : nil
|
|
633
|
-
body ||= {}
|
|
634
694
|
token = body["formToken"] if body.is_a?(Hash)
|
|
635
|
-
|
|
636
695
|
if token.nil? || token.to_s.empty?
|
|
637
|
-
token = headers["X-Form-Token"] || headers["x-form-token"]
|
|
696
|
+
token = headers["X-Form-Token"] || headers["x-form-token"]
|
|
638
697
|
end
|
|
639
698
|
|
|
699
|
+
# 9. Missing token -- reject.
|
|
640
700
|
if token.nil? || token.to_s.empty?
|
|
641
|
-
response.
|
|
642
|
-
return [request, response]
|
|
701
|
+
return [request, response.error("CSRF_INVALID", "Invalid or missing form token", 403)]
|
|
643
702
|
end
|
|
644
703
|
|
|
645
|
-
# Validate the
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
704
|
+
# 10. Validate signature + expiry with the resolved secret. valid_token
|
|
705
|
+
# returns the verified payload Hash (or nil) in 3.13.0+.
|
|
706
|
+
payload = Tina4::Auth.valid_token(token.to_s)
|
|
707
|
+
unless payload
|
|
708
|
+
return [request, response.error("CSRF_INVALID", "Invalid or missing form token", 403)]
|
|
649
709
|
end
|
|
650
710
|
|
|
651
|
-
#
|
|
652
|
-
|
|
711
|
+
# 11. Enforce the form-token TYPE -- a valid signature is not enough. A
|
|
712
|
+
# non-form JWT (e.g. an auth/session token) must never be accepted in
|
|
713
|
+
# the formToken slot.
|
|
714
|
+
payload = {} unless payload.is_a?(Hash)
|
|
715
|
+
if payload["type"] != "form"
|
|
716
|
+
return [request, response.error("CSRF_INVALID", "Invalid or missing form token", 403)]
|
|
717
|
+
end
|
|
718
|
+
|
|
719
|
+
# 12. Session binding -- a token minted for one session cannot be replayed
|
|
720
|
+
# against another. Read the request session's OWN id: Tina4::Session
|
|
721
|
+
# exposes get_session_id (NOT session_id -- reading session_id then
|
|
722
|
+
# session.get("session_id") looked up a DATA key, never the id, so
|
|
723
|
+
# binding silently never fired against a real session). A plain Hash
|
|
724
|
+
# session exposes "session_id".
|
|
653
725
|
token_session_id = payload["session_id"]
|
|
654
726
|
if token_session_id
|
|
655
|
-
current_session_id = nil
|
|
656
727
|
session = request.respond_to?(:session) ? request.session : nil
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
728
|
+
current_session_id =
|
|
729
|
+
if session.nil?
|
|
730
|
+
nil
|
|
731
|
+
elsif session.respond_to?(:session_id)
|
|
732
|
+
session.session_id
|
|
733
|
+
elsif session.respond_to?(:get_session_id)
|
|
734
|
+
session.get_session_id
|
|
735
|
+
elsif session.is_a?(Hash)
|
|
736
|
+
session["session_id"]
|
|
737
|
+
end
|
|
666
738
|
|
|
667
739
|
if current_session_id && token_session_id != current_session_id
|
|
668
|
-
response.
|
|
669
|
-
return [request, response]
|
|
740
|
+
return [request, response.error("CSRF_INVALID", "Invalid or missing form token", 403)]
|
|
670
741
|
end
|
|
671
742
|
end
|
|
672
743
|
|
|
744
|
+
# 13. All checks passed.
|
|
673
745
|
[request, response]
|
|
674
746
|
end
|
|
747
|
+
|
|
748
|
+
# Auto-attach CsrfMiddleware when TINA4_CSRF is enabled in the environment.
|
|
749
|
+
#
|
|
750
|
+
# CSRF is OFF by default: with TINA4_CSRF unset the middleware is never
|
|
751
|
+
# attached, so a default app has no CSRF gate. A truthy value
|
|
752
|
+
# (true/1/yes/on, case-insensitive) attaches it globally so every
|
|
753
|
+
# state-changing route is gated -- the env flag is the switch, no code
|
|
754
|
+
# change needed. Idempotent (Middleware.use de-dupes). Returns true when the
|
|
755
|
+
# middleware is now attached. Mirrors the Python master's
|
|
756
|
+
# attach_csrf_from_env; the framework calls it once at boot
|
|
757
|
+
# (Tina4.initialize!). A false/0/no value still lets an explicit
|
|
758
|
+
# Router.use(CsrfMiddleware) opt-in be disabled at runtime by the kill
|
|
759
|
+
# switch in before_csrf.
|
|
760
|
+
def attach_from_env
|
|
761
|
+
value = ENV["TINA4_CSRF"].to_s.strip.downcase
|
|
762
|
+
if %w[true 1 yes on].include?(value)
|
|
763
|
+
Tina4::Middleware.use(Tina4::CsrfMiddleware)
|
|
764
|
+
return true
|
|
765
|
+
end
|
|
766
|
+
false
|
|
767
|
+
end
|
|
675
768
|
end
|
|
676
769
|
end
|
|
677
770
|
|
|
@@ -684,12 +777,32 @@ module Tina4
|
|
|
684
777
|
# TINA4_PERMISSIONS_POLICY — Permissions-Policy (default: camera=(), microphone=(), geolocation=())
|
|
685
778
|
class SecurityHeadersMiddleware
|
|
686
779
|
class << self
|
|
780
|
+
# Register this middleware in the default chain (secure-by-default).
|
|
781
|
+
#
|
|
782
|
+
# Unlike CSRF (opt-in via TINA4_CSRF) this is UNCONDITIONAL: a default app
|
|
783
|
+
# ships the security headers with no opt-in -- the SECHDR-DEC-01 posture
|
|
784
|
+
# that closes the SECHDR-OFF-BY-DEFAULT gap (the middleware existed with good
|
|
785
|
+
# defaults but was never registered). Idempotent (Middleware.use de-dupes).
|
|
786
|
+
# The framework calls it once at boot (Tina4.initialize!). Returns true.
|
|
787
|
+
def attach
|
|
788
|
+
Tina4::Middleware.use(self)
|
|
789
|
+
true
|
|
790
|
+
end
|
|
791
|
+
|
|
687
792
|
def before_security(request, response)
|
|
688
793
|
response.headers["X-Frame-Options"] = ENV["TINA4_FRAME_OPTIONS"] || "SAMEORIGIN"
|
|
689
794
|
response.headers["X-Content-Type-Options"] = "nosniff"
|
|
690
795
|
|
|
796
|
+
# HSTS is HTTPS-only (SECHDR-DEC-02): a downgrade-protection header on a
|
|
797
|
+
# plain-HTTP response is inert at best and ships a bad max-age on an
|
|
798
|
+
# unencrypted scheme at worst. Emit it ONLY when TINA4_HSTS is set AND the
|
|
799
|
+
# request is HTTPS -- Request.secure_scheme? honours x-forwarded-proto
|
|
800
|
+
# (first hop) then rack.url_scheme, the same source of truth the session
|
|
801
|
+
# cookie's Secure flag uses. Defensive env lookup keeps a non-Request from
|
|
802
|
+
# turning every response into a 500 now that this runs on every request.
|
|
691
803
|
hsts = ENV["TINA4_HSTS"] || ""
|
|
692
|
-
|
|
804
|
+
env = request.respond_to?(:env) ? request.env : {}
|
|
805
|
+
if !hsts.empty? && Tina4::Request.secure_scheme?(env)
|
|
693
806
|
response.headers["Strict-Transport-Security"] = "max-age=#{hsts}; includeSubDomains"
|
|
694
807
|
end
|
|
695
808
|
|
data/lib/tina4/migration.rb
CHANGED
|
@@ -199,14 +199,23 @@ module Tina4
|
|
|
199
199
|
end
|
|
200
200
|
# migration_name is UNIQUE: a migration is "applied" iff a success row
|
|
201
201
|
# exists, so a re-applied name must never duplicate a tracking row.
|
|
202
|
+
# Firebird's column-definition grammar requires DEFAULT to precede
|
|
203
|
+
# NOT NULL (<col> <type> [DEFAULT <value>] [NOT NULL]) -- unlike
|
|
204
|
+
# PostgreSQL/MySQL/SQLite/MSSQL, which accept either order. The
|
|
205
|
+
# previous "NOT NULL DEFAULT 1" order raised "Invalid token ...
|
|
206
|
+
# DEFAULT" (SQL error -104) on a real server; never caught before
|
|
207
|
+
# because no prior test constructed a real Migration against a real
|
|
208
|
+
# Firebird connection (MIG-FBMSSQL-MOCK, feature 15). Mirrors the
|
|
209
|
+
# Python reference (_create_v3_table's Firebird branch already uses
|
|
210
|
+
# this order).
|
|
202
211
|
@db.execute(<<~SQL)
|
|
203
212
|
CREATE TABLE #{TRACKING_TABLE} (
|
|
204
213
|
id INTEGER NOT NULL PRIMARY KEY,
|
|
205
214
|
migration_name VARCHAR(500) NOT NULL UNIQUE,
|
|
206
215
|
description VARCHAR(500),
|
|
207
|
-
batch INTEGER NOT NULL
|
|
216
|
+
batch INTEGER DEFAULT 1 NOT NULL,
|
|
208
217
|
executed_at VARCHAR(50) NOT NULL,
|
|
209
|
-
passed INTEGER NOT NULL
|
|
218
|
+
passed INTEGER DEFAULT 1 NOT NULL
|
|
210
219
|
)
|
|
211
220
|
SQL
|
|
212
221
|
else
|
|
@@ -320,10 +329,13 @@ module Tina4
|
|
|
320
329
|
Tina4::Log.info("Running migration: #{name}")
|
|
321
330
|
begin
|
|
322
331
|
# Wrap each migration FILE in its own transaction so a multi-statement
|
|
323
|
-
# file that fails midway rolls back as a unit. Truly atomic
|
|
324
|
-
#
|
|
325
|
-
#
|
|
326
|
-
#
|
|
332
|
+
# file that fails midway rolls back as a unit. Truly atomic on engines
|
|
333
|
+
# with transactional DDL (PostgreSQL, and SQLite -- autocommit is off
|
|
334
|
+
# inside start_transaction, proven by
|
|
335
|
+
# spec/migration_contract_spec.rb's "a multi-statement failure rolls
|
|
336
|
+
# back the DDL on SQLite too"). MySQL and Firebird auto-commit DDL, so
|
|
337
|
+
# earlier statements may persist there -- keep one logical change per
|
|
338
|
+
# file on those two engines.
|
|
327
339
|
@db.start_transaction
|
|
328
340
|
if file.end_with?(".rb")
|
|
329
341
|
execute_ruby_migration(file, :up)
|
|
@@ -349,23 +361,35 @@ module Tina4
|
|
|
349
361
|
|
|
350
362
|
def rollback_migration(name)
|
|
351
363
|
Tina4::Log.info("Rolling back: #{name}")
|
|
364
|
+
# FAIL-SAFE (MIG-DEC-02, reuses the Python reference model): each
|
|
365
|
+
# rollback is its own transaction, and the ledger row is removed ONLY
|
|
366
|
+
# once the down artifact actually ran (or was confirmed a deliberate
|
|
367
|
+
# no-op empty file) -- never on a missing or failed down. A missing
|
|
368
|
+
# artifact now RAISES instead of Tina4::Log.warning-then-still-deleting,
|
|
369
|
+
# which was the exact MIG-ROLLBACK-DROPS-LEDGER bug: the schema stayed
|
|
370
|
+
# applied but the tracking row vanished, so it was silently forgotten.
|
|
371
|
+
@db.start_transaction
|
|
352
372
|
begin
|
|
353
373
|
file = File.join(@migrations_dir, name)
|
|
354
|
-
if name.end_with?(".rb")
|
|
374
|
+
if name.end_with?(".rb")
|
|
375
|
+
raise "Cannot rollback #{name}: no .rb file found" unless File.exist?(file)
|
|
376
|
+
|
|
355
377
|
execute_ruby_migration(file, :down)
|
|
356
378
|
elsif name.end_with?(".sql")
|
|
357
|
-
down_file = File.join(@migrations_dir, name.sub(
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
379
|
+
down_file = File.join(@migrations_dir, name.sub(/\.sql\z/, ".down.sql"))
|
|
380
|
+
raise "Cannot rollback #{name}: no .down.sql file found" unless File.exist?(down_file)
|
|
381
|
+
|
|
382
|
+
execute_sql_file(down_file)
|
|
383
|
+
else
|
|
384
|
+
raise "Cannot rollback #{name}: unrecognised migration file type"
|
|
363
385
|
end
|
|
364
386
|
_remove_migration_record(name)
|
|
387
|
+
@db.commit
|
|
365
388
|
{ name: name, status: "rolled_back" }
|
|
366
389
|
rescue => e
|
|
390
|
+
@db.rollback rescue nil
|
|
367
391
|
Tina4::Log.error("Rollback failed: #{name} - #{e.message}")
|
|
368
|
-
|
|
392
|
+
raise
|
|
369
393
|
end
|
|
370
394
|
end
|
|
371
395
|
|
|
@@ -593,9 +617,17 @@ module Tina4
|
|
|
593
617
|
|
|
594
618
|
# Check if a column already exists in a Firebird table via RDB$RELATION_FIELDS.
|
|
595
619
|
# Firebird stores unquoted identifiers in upper-case.
|
|
620
|
+
#
|
|
621
|
+
# The selected literal is ALIASED ("1 AS FOUND", not a bare "1"): #fetch_one
|
|
622
|
+
# applies limit: 1, and FirebirdDriver#apply_limit wraps the query in a
|
|
623
|
+
# derived table ("SELECT FIRST 1 SKIP 0 * FROM (<sql>)") -- Firebird REQUIRES
|
|
624
|
+
# every derived-table column to have a name, and a bare unaliased literal has
|
|
625
|
+
# none ("no column name specified for column number 1 in derived table",
|
|
626
|
+
# SQL error -104). Found on a REAL Firebird 5 (MIG-FBMSSQL-MOCK, feature 15)
|
|
627
|
+
# -- the old FakeDB-based spec never executed a real query here at all.
|
|
596
628
|
def firebird_column_exists?(table, column)
|
|
597
629
|
row = @db.fetch_one(
|
|
598
|
-
"SELECT 1 FROM RDB\$RELATION_FIELDS WHERE RDB\$RELATION_NAME = ? AND TRIM(RDB\$FIELD_NAME) = ?",
|
|
630
|
+
"SELECT 1 AS FOUND FROM RDB\$RELATION_FIELDS WHERE RDB\$RELATION_NAME = ? AND TRIM(RDB\$FIELD_NAME) = ?",
|
|
599
631
|
[table.upcase, column.upcase]
|
|
600
632
|
)
|
|
601
633
|
!row.nil?
|