tina4ruby 3.13.97 → 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 -19
- data/lib/tina4/scss/tina4css/_alerts.scss +0 -34
- data/lib/tina4/scss/tina4css/_badges.scss +0 -22
- data/lib/tina4/scss/tina4css/_buttons.scss +0 -69
- data/lib/tina4/scss/tina4css/_cards.scss +0 -49
- data/lib/tina4/scss/tina4css/_forms.scss +0 -156
- data/lib/tina4/scss/tina4css/_grid.scss +0 -81
- data/lib/tina4/scss/tina4css/_modals.scss +0 -84
- data/lib/tina4/scss/tina4css/_nav.scss +0 -149
- data/lib/tina4/scss/tina4css/_pagination.scss +0 -63
- data/lib/tina4/scss/tina4css/_reset.scss +0 -94
- data/lib/tina4/scss/tina4css/_tables.scss +0 -54
- data/lib/tina4/scss/tina4css/_typography.scss +0 -55
- data/lib/tina4/scss/tina4css/_utilities.scss +0 -208
- data/lib/tina4/scss/tina4css/_variables.scss +0 -117
- data/lib/tina4/scss/tina4css/base.scss +0 -1
- data/lib/tina4/scss/tina4css/colors.scss +0 -48
- data/lib/tina4/scss/tina4css/tina4.scss +0 -18
data/lib/tina4/request.rb
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
require "uri"
|
|
3
3
|
require "json"
|
|
4
4
|
require "ipaddr"
|
|
5
|
+
require "stringio"
|
|
5
6
|
|
|
6
7
|
module Tina4
|
|
7
8
|
# A Hash subclass that supports indifferent access (both string and symbol keys).
|
|
@@ -115,14 +116,87 @@ module Tina4
|
|
|
115
116
|
|
|
116
117
|
class Request
|
|
117
118
|
attr_reader :env, :method, :path, :query_string, :content_type,
|
|
118
|
-
:
|
|
119
|
-
|
|
119
|
+
:ip, :remote_ip
|
|
120
|
+
# :route is the matched Route, attached by the dispatcher before post-match
|
|
121
|
+
# middleware runs (DispatchPipeline#prepare_route_request). CsrfMiddleware
|
|
122
|
+
# reads route.auth_required to honour a public write route (.no_auth).
|
|
123
|
+
attr_accessor :user, :route
|
|
120
124
|
|
|
121
125
|
# Maximum upload size in bytes (default 10 MB). Override via TINA4_MAX_UPLOAD_SIZE env var.
|
|
122
126
|
TINA4_MAX_UPLOAD_SIZE = Integer(ENV.fetch("TINA4_MAX_UPLOAD_SIZE", 10_485_760))
|
|
123
127
|
|
|
124
128
|
class PayloadTooLarge < StandardError; end
|
|
125
129
|
|
|
130
|
+
# Effective upload cap in bytes. Read at CALL TIME (not frozen into the
|
|
131
|
+
# constant at load) so the limit honours a TINA4_MAX_UPLOAD_SIZE set after
|
|
132
|
+
# this file was required, and so a test can lower it. Falls back to the
|
|
133
|
+
# constant default when the env var is unset/blank.
|
|
134
|
+
def self.max_upload_size
|
|
135
|
+
value = ENV["TINA4_MAX_UPLOAD_SIZE"]
|
|
136
|
+
value.nil? || value.empty? ? TINA4_MAX_UPLOAD_SIZE : value.to_i
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Read an IO in bounded chunks, raising PayloadTooLarge the MOMENT the
|
|
140
|
+
# running total exceeds the upload cap, so an over-limit body is refused as
|
|
141
|
+
# it arrives instead of after the whole thing is buffered. Rewinds the input
|
|
142
|
+
# before and after so a later reader (Rack's parser, form-token extraction)
|
|
143
|
+
# sees the same stream. Parity with the Python/Node per-chunk body readers.
|
|
144
|
+
def self.read_stream_capped(input, limit = nil)
|
|
145
|
+
return "" unless input
|
|
146
|
+
|
|
147
|
+
limit = max_upload_size if limit.nil?
|
|
148
|
+
input.rewind if input.respond_to?(:rewind)
|
|
149
|
+
buffer = +""
|
|
150
|
+
if limit&.positive?
|
|
151
|
+
while (chunk = input.read(65_536))
|
|
152
|
+
buffer << chunk
|
|
153
|
+
if buffer.bytesize > limit
|
|
154
|
+
raise PayloadTooLarge,
|
|
155
|
+
"Request body (#{buffer.bytesize}+ bytes) exceeds TINA4_MAX_UPLOAD_SIZE (#{limit} bytes)"
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
else
|
|
159
|
+
buffer = input.read || ""
|
|
160
|
+
end
|
|
161
|
+
input.rewind if input.respond_to?(:rewind)
|
|
162
|
+
buffer
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Persist an uploaded file's content inside target_dir under a SAFE name.
|
|
166
|
+
#
|
|
167
|
+
# The client-supplied filename is untrusted. Directory components are
|
|
168
|
+
# stripped (so "../../evil" or "/etc/passwd" becomes "evil"/"passwd"), a NUL
|
|
169
|
+
# byte or an unusable name ("", ".", "..") is refused, and the resolved path
|
|
170
|
+
# is confined to target_dir (realpath containment) so an upload can never
|
|
171
|
+
# write outside it. Returns the absolute path written; raises ArgumentError
|
|
172
|
+
# on an unsafe name.
|
|
173
|
+
def self.save_upload(file, target_dir, filename: nil)
|
|
174
|
+
raw = (filename || file["filename"] || file[:filename] || "").to_s
|
|
175
|
+
raise ArgumentError, "upload filename contains a null byte" if raw.include?("\u0000")
|
|
176
|
+
|
|
177
|
+
# Reduce to a single path segment, handling BOTH separators so a Windows
|
|
178
|
+
# "..\\..\\evil" cannot smuggle a directory part past a POSIX basename.
|
|
179
|
+
base = raw.tr("\\", "/").split("/").last.to_s
|
|
180
|
+
if base.empty? || base == "." || base == ".."
|
|
181
|
+
raise ArgumentError, "upload filename is not a usable name: #{raw.inspect}"
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
require "fileutils"
|
|
185
|
+
FileUtils.mkdir_p(target_dir)
|
|
186
|
+
dest = File.join(target_dir, base)
|
|
187
|
+
# Defence in depth: the resolved parent of the destination must be exactly
|
|
188
|
+
# the resolved target dir (guards a pre-existing symlink at target/base).
|
|
189
|
+
real_dir = File.realpath(target_dir)
|
|
190
|
+
real_parent = File.realpath(File.dirname(dest))
|
|
191
|
+
unless real_parent == real_dir
|
|
192
|
+
raise ArgumentError, "refusing to write outside #{target_dir.inspect}: #{raw.inspect}"
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
content = file["content"] || file[:content] || ""
|
|
196
|
+
File.binwrite(dest, content)
|
|
197
|
+
dest
|
|
198
|
+
end
|
|
199
|
+
|
|
126
200
|
def initialize(env, path_params = {})
|
|
127
201
|
@env = env
|
|
128
202
|
@method = env["REQUEST_METHOD"]
|
|
@@ -131,11 +205,14 @@ module Tina4
|
|
|
131
205
|
@content_type = env["CONTENT_TYPE"] || ""
|
|
132
206
|
@path_params = path_params
|
|
133
207
|
|
|
134
|
-
# Check upload size limit
|
|
208
|
+
# Check upload size limit (DECLARED Content-Length). The running per-chunk
|
|
209
|
+
# counter in read_stream_capped catches the chunked / under-declared case
|
|
210
|
+
# this check cannot see.
|
|
135
211
|
content_length = (env["CONTENT_LENGTH"] || 0).to_i
|
|
136
|
-
|
|
212
|
+
upload_limit = Tina4::Request.max_upload_size
|
|
213
|
+
if content_length > upload_limit
|
|
137
214
|
raise PayloadTooLarge,
|
|
138
|
-
"Request body (#{content_length} bytes) exceeds TINA4_MAX_UPLOAD_SIZE (#{
|
|
215
|
+
"Request body (#{content_length} bytes) exceeds TINA4_MAX_UPLOAD_SIZE (#{upload_limit} bytes)"
|
|
139
216
|
end
|
|
140
217
|
|
|
141
218
|
# Raw socket peer — NEVER honours X-Forwarded-For, so it can be trusted
|
|
@@ -154,6 +231,11 @@ module Tina4
|
|
|
154
231
|
@json_body = nil
|
|
155
232
|
@query_hash = nil
|
|
156
233
|
@body_parsed = nil
|
|
234
|
+
# #body's own memoised RESULT can legitimately be nil (the no-body
|
|
235
|
+
# sentinel, REQ-BODY-DIVERGE 3.13.99), so "nil = not yet computed" (the
|
|
236
|
+
# convention above) cannot also mean "computed, and nil" for this one
|
|
237
|
+
# field — a dedicated flag distinguishes them.
|
|
238
|
+
@body_parsed_computed = false
|
|
157
239
|
end
|
|
158
240
|
|
|
159
241
|
# Is this request HTTPS from the CLIENT's point of view?
|
|
@@ -226,8 +308,15 @@ module Tina4
|
|
|
226
308
|
# fields Hash, else the current fallback). This matches Python's
|
|
227
309
|
# `request.body`, PHP's, and Node's: `body` is the PARSED payload, not
|
|
228
310
|
# the raw bytes. For the raw string use `body_raw`.
|
|
311
|
+
#
|
|
312
|
+
# No-body is now a real `nil` result (REQ-BODY-DIVERGE, 3.13.99), so this
|
|
313
|
+
# can no longer memoise with `||=` (nil/false never "stick" — every call
|
|
314
|
+
# would re-run parse_body). @body_parsed_computed distinguishes "never
|
|
315
|
+
# computed" from "computed and the answer was nil".
|
|
229
316
|
def body
|
|
230
|
-
@body_parsed
|
|
317
|
+
return @body_parsed if @body_parsed_computed
|
|
318
|
+
@body_parsed_computed = true
|
|
319
|
+
@body_parsed = parse_body
|
|
231
320
|
end
|
|
232
321
|
|
|
233
322
|
# Raw body string — the bytes exactly as the client sent them.
|
|
@@ -250,40 +339,58 @@ module Tina4
|
|
|
250
339
|
@files ||= extract_files
|
|
251
340
|
end
|
|
252
341
|
|
|
253
|
-
#
|
|
254
|
-
#
|
|
255
|
-
#
|
|
342
|
+
# Route params ONLY — never query or body (REQ-PARAM-POLLUTION, 3.13.99,
|
|
343
|
+
# a param-pollution/security fix). A route `/{id}` hit with `?id=other`
|
|
344
|
+
# yields `params["id"]` == the route value; the client value is only ever
|
|
345
|
+
# in `query`. Supports both string and symbol key access (indifferent
|
|
346
|
+
# access — matches Route#match_path, which captures path-param names as
|
|
347
|
+
# SYMBOLS, so `params[:id]` and `params["id"]` both resolve). Renamed
|
|
348
|
+
# from `path_params` to unify the route-param accessor NAME with
|
|
349
|
+
# Python/PHP/Node (REQ-ROUTE-PARAM-NAME); the old MERGED `params`
|
|
350
|
+
# (query + body + path_params, via #build_params) is deleted outright —
|
|
351
|
+
# no back-compat alias (nothing in the ledger asked for one).
|
|
256
352
|
#
|
|
257
|
-
# The request is built BEFORE route matching
|
|
258
|
-
#
|
|
353
|
+
# The request is built BEFORE route matching, so pre-match middleware has
|
|
354
|
+
# something to read and mutate. Path params are only known once a route
|
|
259
355
|
# has matched, so they are set here and the memoised #params is dropped -
|
|
260
356
|
# without that reset a pre-match middleware that touched #params would
|
|
261
357
|
# freeze a param-less copy for the handler.
|
|
262
|
-
def
|
|
358
|
+
def params=(value)
|
|
263
359
|
@path_params = value || {}
|
|
264
360
|
@params = nil
|
|
265
361
|
end
|
|
266
362
|
|
|
267
|
-
attr_reader :path_params
|
|
268
|
-
|
|
269
363
|
def params
|
|
270
|
-
@params ||=
|
|
364
|
+
@params ||= begin
|
|
365
|
+
result = IndifferentHash.new
|
|
366
|
+
@path_params.each { |k, v| result[k] = v }
|
|
367
|
+
result
|
|
368
|
+
end
|
|
271
369
|
end
|
|
272
370
|
|
|
273
|
-
# Look up a
|
|
371
|
+
# Look up a value by key: the matched ROUTE param first, then the query
|
|
372
|
+
# string. A read convenience only — `params` and `query` stay separate
|
|
373
|
+
# collections (REQ-PARAM-POLLUTION); a route value always wins over a
|
|
374
|
+
# client-supplied query value of the same name. Mirrors PHP's/Node's
|
|
375
|
+
# `param()`. Accepts a symbol or string key (indifferent, like `params`).
|
|
274
376
|
def param(key, default = nil)
|
|
275
|
-
|
|
377
|
+
value = params[key]
|
|
378
|
+
return value unless value.nil?
|
|
379
|
+
query[key.to_s] || default
|
|
276
380
|
end
|
|
277
381
|
|
|
278
382
|
def [](key)
|
|
279
|
-
|
|
383
|
+
param(key)
|
|
280
384
|
end
|
|
281
385
|
|
|
282
386
|
def header(name)
|
|
283
387
|
# Headers are stored in a CaseInsensitiveHash keyed by lowercase-
|
|
284
|
-
# dashed names ("content-type", "x-api-key"). The hash normalises
|
|
285
|
-
#
|
|
286
|
-
|
|
388
|
+
# dashed names ("content-type", "x-api-key"). The hash normalises the
|
|
389
|
+
# lookup CASE automatically; this only translates the DASH convention.
|
|
390
|
+
# No underscore->dash remap any more (REQ-HEADER-DASH-DIVERGE,
|
|
391
|
+
# 3.13.99): case-fold only, matching the PHP/Node reference — a caller
|
|
392
|
+
# passing "content_type" no longer matches "Content-Type".
|
|
393
|
+
headers[name.to_s]
|
|
287
394
|
end
|
|
288
395
|
|
|
289
396
|
def json_body
|
|
@@ -381,9 +488,17 @@ module Tina4
|
|
|
381
488
|
existing = @env["rack.request.form_hash"] rescue nil
|
|
382
489
|
return existing if existing
|
|
383
490
|
|
|
491
|
+
# Enforce the running per-chunk upload cap BEFORE Rack reads the body, so
|
|
492
|
+
# an over-limit body is refused as it arrives rather than after Rack has
|
|
493
|
+
# buffered the whole thing. Outside the begin/rescue below on purpose: the
|
|
494
|
+
# PayloadTooLarge must propagate to the 413 handler, not be swallowed.
|
|
495
|
+
Tina4::Request.read_stream_capped(@env["rack.input"])
|
|
496
|
+
|
|
384
497
|
parsed = begin
|
|
385
498
|
require "rack"
|
|
386
499
|
Rack::Request.new(@env).POST
|
|
500
|
+
rescue Tina4::Request::PayloadTooLarge
|
|
501
|
+
raise
|
|
387
502
|
rescue StandardError => e
|
|
388
503
|
Tina4::Log.warning("multipart parse failed: #{e.message}") if defined?(Tina4::Log)
|
|
389
504
|
nil
|
|
@@ -392,8 +507,24 @@ module Tina4
|
|
|
392
507
|
end
|
|
393
508
|
|
|
394
509
|
def parse_body
|
|
510
|
+
# No-body sentinel (REQ-BODY-DIVERGE, 3.13.99): checked FIRST, before any
|
|
511
|
+
# content-type-specific parsing, so a request with no body is nil —
|
|
512
|
+
# Ruby's own "nothing" value, matching Python's None/PHP's null/Node's
|
|
513
|
+
# undefined (each language's native absence-value; was {} here, the odd
|
|
514
|
+
# one out).
|
|
515
|
+
return nil if body_raw.nil? || body_raw.empty?
|
|
516
|
+
|
|
395
517
|
if @content_type.include?("application/json")
|
|
396
|
-
|
|
518
|
+
# Malformed JSON -> the RAW STRING (REQ-BODY-DIVERGE, 3.13.99 — pinned
|
|
519
|
+
# to the Python/PHP/Node majority; was {} here via #json_body, which
|
|
520
|
+
# swallows the distinction between "invalid" and "absent"). Deliberately
|
|
521
|
+
# NOT delegating to #json_body: that method keeps its own documented
|
|
522
|
+
# always-a-Hash, {}-on-failure contract for direct callers.
|
|
523
|
+
begin
|
|
524
|
+
JSON.parse(body_raw)
|
|
525
|
+
rescue JSON::ParserError, TypeError
|
|
526
|
+
body_raw
|
|
527
|
+
end
|
|
397
528
|
elsif @content_type.include?("application/x-www-form-urlencoded")
|
|
398
529
|
parse_query_to_hash(body_raw)
|
|
399
530
|
elsif @content_type.include?("multipart/form-data")
|
|
@@ -403,8 +534,10 @@ module Tina4
|
|
|
403
534
|
form_hash = multipart_form_hash
|
|
404
535
|
if form_hash
|
|
405
536
|
form_hash.each do |key, value|
|
|
406
|
-
# Skip file entries (handled by extract_files)
|
|
537
|
+
# Skip file entries (handled by extract_files) - a single descriptor
|
|
538
|
+
# or a list of them (repeated field name).
|
|
407
539
|
next if value.is_a?(Hash) && value[:tempfile]
|
|
540
|
+
next if value.is_a?(Array) && value.any? { |v| v.is_a?(Hash) && v[:tempfile] }
|
|
408
541
|
result[key] = value
|
|
409
542
|
end
|
|
410
543
|
end
|
|
@@ -414,20 +547,6 @@ module Tina4
|
|
|
414
547
|
end
|
|
415
548
|
end
|
|
416
549
|
|
|
417
|
-
def build_params
|
|
418
|
-
p = IndifferentHash.new
|
|
419
|
-
|
|
420
|
-
# Query string params
|
|
421
|
-
query.each { |k, v| p[k.to_s] = v }
|
|
422
|
-
|
|
423
|
-
# Body params
|
|
424
|
-
body_parsed.each { |k, v| p[k.to_s] = v }
|
|
425
|
-
|
|
426
|
-
# Path params (highest priority)
|
|
427
|
-
@path_params.each { |k, v| p[k.to_s] = v }
|
|
428
|
-
p
|
|
429
|
-
end
|
|
430
|
-
|
|
431
550
|
def parse_query_to_hash(qs)
|
|
432
551
|
result = {}
|
|
433
552
|
return result if qs.nil? || qs.empty?
|
|
@@ -442,30 +561,119 @@ module Tina4
|
|
|
442
561
|
result = {}
|
|
443
562
|
return result unless @content_type.include?("multipart/form-data")
|
|
444
563
|
begin
|
|
564
|
+
# LIVE path: hand-scan the raw body so a REPEATED file field name is
|
|
565
|
+
# preserved as a LIST (Rack's POST collapses a repeated non-bracket name
|
|
566
|
+
# to last-wins - the multi-file data-loss this fixes). The scan reads the
|
|
567
|
+
# body through the capped reader, so an over-limit upload is refused as
|
|
568
|
+
# it arrives. Falls through to the parsed form_hash when there is no raw
|
|
569
|
+
# body (e.g. a spec injecting rack.request.form_hash).
|
|
570
|
+
scanned = scan_multipart_files
|
|
571
|
+
if scanned && !scanned.empty?
|
|
572
|
+
scanned.each do |key, list|
|
|
573
|
+
files = list.map { |value| build_file_upload(value) }.compact
|
|
574
|
+
result[key] = files.length == 1 ? files.first : files unless files.empty?
|
|
575
|
+
end
|
|
576
|
+
return result
|
|
577
|
+
end
|
|
578
|
+
|
|
445
579
|
form_hash = multipart_form_hash
|
|
446
580
|
if form_hash
|
|
447
581
|
form_hash.each do |key, value|
|
|
448
|
-
if value.is_a?(
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
# first access (see FileUpload) — :tempfile-only handlers never
|
|
455
|
-
# buffer large uploads in memory.
|
|
456
|
-
file = FileUpload.new
|
|
457
|
-
file[:filename] = value[:filename]
|
|
458
|
-
file[:type] = value[:type]
|
|
459
|
-
file[:tempfile] = tempfile
|
|
460
|
-
file[:size] = tempfile.size
|
|
461
|
-
result[key] = file
|
|
582
|
+
if value.is_a?(Array)
|
|
583
|
+
files = value.map { |v| build_file_upload(v) }.compact
|
|
584
|
+
result[key] = files.length == 1 ? files.first : files unless files.empty?
|
|
585
|
+
else
|
|
586
|
+
file = build_file_upload(value)
|
|
587
|
+
result[key] = file if file
|
|
462
588
|
end
|
|
463
589
|
end
|
|
464
590
|
end
|
|
591
|
+
rescue Tina4::Request::PayloadTooLarge
|
|
592
|
+
raise
|
|
465
593
|
rescue StandardError
|
|
466
594
|
# Multipart parsing failed
|
|
467
595
|
end
|
|
468
596
|
result
|
|
469
597
|
end
|
|
598
|
+
|
|
599
|
+
# Build an indifferent-access per-file hash from a raw descriptor value
|
|
600
|
+
# ({filename:, type:, tempfile:, [content], [size]}). file["content"],
|
|
601
|
+
# file[:content], file["filename"] etc. all work; `content` (raw bytes,
|
|
602
|
+
# never base64) is materialised lazily from the tempfile on first access
|
|
603
|
+
# (see FileUpload) unless the scan already supplied it.
|
|
604
|
+
def build_file_upload(value)
|
|
605
|
+
return nil unless value.is_a?(Hash) && value[:tempfile]
|
|
606
|
+
|
|
607
|
+
file = FileUpload.new
|
|
608
|
+
file[:filename] = value[:filename]
|
|
609
|
+
file[:type] = value[:type]
|
|
610
|
+
file[:tempfile] = value[:tempfile]
|
|
611
|
+
file[:size] = value[:size] || (value[:tempfile].size rescue 0)
|
|
612
|
+
file["content"] = value[:content] if value.key?(:content) && !value[:content].nil?
|
|
613
|
+
file
|
|
614
|
+
end
|
|
615
|
+
|
|
616
|
+
# Extract the boundary token from a multipart Content-Type header.
|
|
617
|
+
def multipart_boundary(content_type)
|
|
618
|
+
content_type.to_s.split(";").each do |part|
|
|
619
|
+
part = part.strip
|
|
620
|
+
next unless part.start_with?("boundary=")
|
|
621
|
+
|
|
622
|
+
return part[9..].to_s.delete_prefix('"').delete_suffix('"')
|
|
623
|
+
end
|
|
624
|
+
nil
|
|
625
|
+
end
|
|
626
|
+
|
|
627
|
+
# Hand-roll the FILE parts out of the raw multipart body, keyed by field
|
|
628
|
+
# name, each name mapping to a LIST of descriptors so a repeated name keeps
|
|
629
|
+
# every file. Reads through read_stream_capped, so the running per-chunk
|
|
630
|
+
# upload cap is enforced here too (raising PayloadTooLarge). Returns {} when
|
|
631
|
+
# there is no raw body (the injected-form_hash path is used instead). Fields
|
|
632
|
+
# are handled by parse_body (via Rack) - this scans files only.
|
|
633
|
+
def scan_multipart_files
|
|
634
|
+
boundary = multipart_boundary(@content_type)
|
|
635
|
+
return {} unless boundary
|
|
636
|
+
|
|
637
|
+
body = Tina4::Request.read_stream_capped(@env["rack.input"])
|
|
638
|
+
return {} if body.nil? || body.empty?
|
|
639
|
+
|
|
640
|
+
body = body.dup.force_encoding("BINARY")
|
|
641
|
+
files = {}
|
|
642
|
+
delimiter = "--#{boundary}"
|
|
643
|
+
body.split(delimiter).each do |segment|
|
|
644
|
+
next if segment.empty? || segment.start_with?("--")
|
|
645
|
+
|
|
646
|
+
segment = segment.sub(/\A\r\n/, "")
|
|
647
|
+
header_end = segment.index("\r\n\r\n")
|
|
648
|
+
next unless header_end
|
|
649
|
+
|
|
650
|
+
header_section = segment[0...header_end]
|
|
651
|
+
content = segment[(header_end + 4)..] || ""
|
|
652
|
+
content = content.sub(/\r\n\z/, "")
|
|
653
|
+
|
|
654
|
+
name = nil
|
|
655
|
+
filename = nil
|
|
656
|
+
type = "application/octet-stream"
|
|
657
|
+
header_section.split("\r\n").each do |line|
|
|
658
|
+
if line =~ /content-disposition/i
|
|
659
|
+
name = Regexp.last_match(1) if line =~ /name="([^"]*)"/
|
|
660
|
+
filename = Regexp.last_match(1) if line =~ /filename="([^"]*)"/
|
|
661
|
+
elsif line =~ /content-type:\s*(.+)/i
|
|
662
|
+
type = Regexp.last_match(1).strip
|
|
663
|
+
end
|
|
664
|
+
end
|
|
665
|
+
next if name.nil? || filename.nil?
|
|
666
|
+
|
|
667
|
+
bytes = content.dup.force_encoding("BINARY")
|
|
668
|
+
(files[name] ||= []) << {
|
|
669
|
+
filename: filename,
|
|
670
|
+
type: type,
|
|
671
|
+
content: bytes,
|
|
672
|
+
size: bytes.bytesize,
|
|
673
|
+
tempfile: StringIO.new(bytes)
|
|
674
|
+
}
|
|
675
|
+
end
|
|
676
|
+
files
|
|
677
|
+
end
|
|
470
678
|
end
|
|
471
679
|
end
|
data/lib/tina4/router.rb
CHANGED
|
@@ -130,7 +130,7 @@ module Tina4
|
|
|
130
130
|
|
|
131
131
|
# Same `false` row of the return-value table a before_* hook gets:
|
|
132
132
|
# keep the response the filter set, 403 only if it set nothing.
|
|
133
|
-
Tina4::Middleware.refuse(response)
|
|
133
|
+
Tina4::Middleware.refuse(request, response)
|
|
134
134
|
return false
|
|
135
135
|
else
|
|
136
136
|
return false unless Tina4::Middleware.run_before([mw], request, response)
|
|
@@ -674,6 +674,24 @@ module Tina4
|
|
|
674
674
|
GroupContext.new(prefix, auth_handler, middleware).instance_eval(&block)
|
|
675
675
|
end
|
|
676
676
|
|
|
677
|
+
# Join a route-group prefix with a route's own path.
|
|
678
|
+
#
|
|
679
|
+
# Feature 32 (RG-DEC-01): ports PHP's normalization grammar verbatim
|
|
680
|
+
# (Tina4/Router.php addRoute - the reference) so Ruby converges with
|
|
681
|
+
# PHP/Python/Node instead of GroupContext's old bare concatenation. One
|
|
682
|
+
# separator between prefix and path, a single leading slash, no
|
|
683
|
+
# trailing slash, and any run of slashes collapsed to one - so
|
|
684
|
+
# group("/api") + get("users"), get("/users"), and group("/api/") +
|
|
685
|
+
# get("/users") all resolve to the SAME "/api/users". Before this fix,
|
|
686
|
+
# "#{@prefix}#{path}" bare-concatenated, so group("/api") +
|
|
687
|
+
# get("users") silently mis-registered at "/apiusers" (and a doubled
|
|
688
|
+
# trailing slash on a prefix could leave "/api//users").
|
|
689
|
+
def join_group_path(prefix, path)
|
|
690
|
+
full = "#{prefix}/#{path.sub(%r{\A/+}, '')}"
|
|
691
|
+
full = "/#{full.gsub(%r{\A/+|/+\z}, '')}"
|
|
692
|
+
full.gsub(%r{/+}, "/")
|
|
693
|
+
end
|
|
694
|
+
|
|
677
695
|
# Load route files from a directory (file-based route discovery).
|
|
678
696
|
#
|
|
679
697
|
# mtime-tracked & re-runnable so re-discovery on /__dev/api/reload is
|
|
@@ -782,7 +800,7 @@ module Tina4
|
|
|
782
800
|
|
|
783
801
|
%w[get post put patch delete any].each do |m|
|
|
784
802
|
define_method(m) do |path, middleware: [], swagger_meta: {}, template: nil, &handler|
|
|
785
|
-
full_path =
|
|
803
|
+
full_path = Tina4::Router.join_group_path(@prefix, path)
|
|
786
804
|
combined_middleware = @middleware + middleware
|
|
787
805
|
Tina4::Router.add(m, full_path, handler,
|
|
788
806
|
auth_handler: @auth_handler,
|
data/lib/tina4/seeder.rb
CHANGED
|
@@ -12,6 +12,20 @@ module Tina4
|
|
|
12
12
|
# fake.name # => "Sarah Johnson"
|
|
13
13
|
# fake.email # => "sarah.johnson123@example.com"
|
|
14
14
|
# fake.integer(1, 100)
|
|
15
|
+
#
|
|
16
|
+
# Determinism is PER-LANGUAGE, not cross-language (SEED-DETERMINISM-PERLANG):
|
|
17
|
+
# +FakeData.new(seed: 42)+ reproduces the identical sequence on every run
|
|
18
|
+
# *within Ruby*, but the same seed on Python/PHP/Node's +FakeData+ will NOT
|
|
19
|
+
# produce the same values -- each language uses its own PRNG (Ruby's
|
|
20
|
+
# +Random+, Python's Mersenne Twister, PHP's per-instance Mt19937, Node's
|
|
21
|
+
# mulberry32). There is no shared cross-language PRNG, and hand-rolling one
|
|
22
|
+
# would add cost for no real benefit -- use a seed to make ONE language's
|
|
23
|
+
# run reproducible, never to compare output across languages.
|
|
24
|
+
#
|
|
25
|
+
# NOT FOR SECRETS (SEED-SECRETS-DOC): this is a non-cryptographic PRNG meant
|
|
26
|
+
# for realistic-looking fixtures and test data. Never use it to generate API
|
|
27
|
+
# keys, passwords, tokens, or anything else that must be unguessable -- use
|
|
28
|
+
# +SecureRandom+ (or +Tina4::Auth+ for password hashing) instead.
|
|
15
29
|
class FakeData
|
|
16
30
|
FIRST_NAMES = %w[
|
|
17
31
|
James Mary Robert Patricia John Jennifer Michael Linda David Elizabeth
|
|
@@ -171,7 +185,7 @@ module Tina4
|
|
|
171
185
|
end
|
|
172
186
|
|
|
173
187
|
def boolean
|
|
174
|
-
@rng.rand(2)
|
|
188
|
+
@rng.rand(2) == 1
|
|
175
189
|
end
|
|
176
190
|
|
|
177
191
|
def datetime(start_year: 2020, end_year: 2026)
|
|
@@ -439,12 +453,19 @@ module Tina4
|
|
|
439
453
|
# @param clear [Boolean] delete existing records before seeding (P2)
|
|
440
454
|
# @param seed [Integer, nil] random seed for reproducible data (P3)
|
|
441
455
|
# @param strict [Boolean] re-raise on the first failed row instead of skipping (P1)
|
|
456
|
+
# @param idempotent [Boolean] SEED-RUBY-QUIRKS fix (default false, matching
|
|
457
|
+
# Python/PHP/Node — none of the other three ever skip seeding based on
|
|
458
|
+
# existing row count). When true, opt in to the OLD Ruby-only shortcut:
|
|
459
|
+
# skip the run entirely (returning a zero SeedSummary, INFO-logged) when
|
|
460
|
+
# the table already holds >= count rows — even rows UNRELATED to this
|
|
461
|
+
# seeder. That check used to run unconditionally, which silently dropped
|
|
462
|
+
# data a caller explicitly asked to seed; it is now opt-in only.
|
|
442
463
|
# @return [SeedSummary] +{seeded, failed, errors}+ — also usable as the int count
|
|
443
464
|
#
|
|
444
465
|
# @example
|
|
445
466
|
# Tina4.seed_orm(User, count: 50)
|
|
446
467
|
# Tina4.seed_orm(Order, count: 200, overrides: { status: ->(f) { f.choice(%w[pending shipped]) } })
|
|
447
|
-
def self.seed_orm(orm_class, count: 10, overrides: {}, clear: false, seed: nil, strict: false)
|
|
468
|
+
def self.seed_orm(orm_class, count: 10, overrides: {}, clear: false, seed: nil, strict: false, idempotent: false)
|
|
448
469
|
fake = FakeData.new(seed: seed)
|
|
449
470
|
fields = orm_class.field_definitions
|
|
450
471
|
table = orm_class.table_name
|
|
@@ -460,13 +481,18 @@ module Tina4
|
|
|
460
481
|
return SeedSummary.new
|
|
461
482
|
end
|
|
462
483
|
|
|
463
|
-
# Idempotency short-circuit
|
|
464
|
-
#
|
|
465
|
-
|
|
484
|
+
# Idempotency short-circuit — OPT-IN (idempotent: true), off by default.
|
|
485
|
+
# It used to run unconditionally (SEED-RUBY-QUIRKS): any table already
|
|
486
|
+
# holding >= count rows was skipped silently, even rows with nothing to
|
|
487
|
+
# do with this seeder, so a caller who explicitly asked to seed could get
|
|
488
|
+
# zero new rows with only an INFO log to explain it. Python/PHP/Node never
|
|
489
|
+
# had this behaviour, so the default now matches them; the check itself is
|
|
490
|
+
# unchanged and still available for a caller who deliberately wants it.
|
|
491
|
+
if idempotent && !clear
|
|
466
492
|
begin
|
|
467
493
|
result = db.fetch_one("SELECT count(*) as cnt FROM #{table}")
|
|
468
494
|
if result && result[:cnt].to_i >= count
|
|
469
|
-
Tina4::Log.info("Seeder: #{table} already has #{result[:cnt]} records, skipping")
|
|
495
|
+
Tina4::Log.info("Seeder: #{table} already has #{result[:cnt]} records, skipping (idempotent: true)")
|
|
470
496
|
return SeedSummary.new
|
|
471
497
|
end
|
|
472
498
|
rescue => e
|
|
@@ -544,12 +570,23 @@ module Tina4
|
|
|
544
570
|
# @param count [Integer] number of records to insert
|
|
545
571
|
# @param overrides [Hash] static values (or callables) set on every row
|
|
546
572
|
# @param clear [Boolean] delete every existing row before seeding (P2)
|
|
547
|
-
# @param seed [Integer, nil] random seed — seeds the FakeData RNG used for any
|
|
548
|
-
# generator that is not an explicit callable (P3 / signature parity)
|
|
549
573
|
# @param strict [Boolean] re-raise on the first failed row instead of skipping (P1)
|
|
550
574
|
# @return [SeedSummary] +{seeded, failed, errors}+ — also usable as the int count
|
|
551
|
-
|
|
552
|
-
|
|
575
|
+
#
|
|
576
|
+
# REMOVED: the +seed:+ keyword (SEED-TABLE-SEED-INERT, SEED-DEC-01, ratified
|
|
577
|
+
# 2026-08-11 — same principle as the no-op ForeignKeyField +on_delete+).
|
|
578
|
+
# Passing +seed:+ now raises +ArgumentError: unknown keyword: :seed+ (Ruby's
|
|
579
|
+
# own signature error — loud and specific, nothing extra to build). For a
|
|
580
|
+
# reproducible run, build your own seeded +FakeData+ and close over it in
|
|
581
|
+
# +columns+:
|
|
582
|
+
#
|
|
583
|
+
# fake = Tina4::FakeData.new(seed: 42)
|
|
584
|
+
# Tina4.seed_table("users", { name: -> { fake.first_name }, age: -> { fake.integer(min: 18, max: 65) } })
|
|
585
|
+
#
|
|
586
|
+
# +seed_orm+/+seed_models+ are unaffected — they build and seed their own
|
|
587
|
+
# +FakeData+ internally, so their +seed:+ argument is fully deterministic.
|
|
588
|
+
def self.seed_table(table_name, columns, count: 10, overrides: {}, clear: false, strict: false)
|
|
589
|
+
fake = FakeData.new
|
|
553
590
|
db = Tina4.database
|
|
554
591
|
|
|
555
592
|
unless db
|
|
@@ -618,8 +655,9 @@ module Tina4
|
|
|
618
655
|
# @param clear [Boolean] clear each table first (reverse-topo order)
|
|
619
656
|
# @param seed [Integer, nil] PRNG seed (P3) — applied per model
|
|
620
657
|
# @param strict [Boolean] re-raise on the first failed row
|
|
658
|
+
# @param idempotent [Boolean] see {seed_orm} — default false, opt-in only
|
|
621
659
|
# @return [Hash] +{ "ModelName" => SeedSummary }+ for each model seeded
|
|
622
|
-
def self.seed_models(orm_classes, count: 10, overrides: {}, clear: false, seed: nil, strict: false)
|
|
660
|
+
def self.seed_models(orm_classes, count: 10, overrides: {}, clear: false, seed: nil, strict: false, idempotent: false)
|
|
623
661
|
ordered = _topo_sort_models(orm_classes)
|
|
624
662
|
|
|
625
663
|
if clear
|
|
@@ -634,7 +672,7 @@ module Tina4
|
|
|
634
672
|
end
|
|
635
673
|
results[model.name] = seed_orm(
|
|
636
674
|
model, count: count, overrides: model_overrides || {},
|
|
637
|
-
clear: false, seed: seed, strict: strict
|
|
675
|
+
clear: false, seed: seed, strict: strict, idempotent: idempotent
|
|
638
676
|
)
|
|
639
677
|
end
|
|
640
678
|
results
|
|
@@ -657,7 +695,7 @@ module Tina4
|
|
|
657
695
|
# { orm_class: User, count: 20 },
|
|
658
696
|
# { orm_class: Order, count: 100, overrides: { status: "pending" } }
|
|
659
697
|
# ], clear: true)
|
|
660
|
-
def self.seed_batch(tasks, clear: false, strict: false)
|
|
698
|
+
def self.seed_batch(tasks, clear: false, strict: false, idempotent: false)
|
|
661
699
|
by_class = {}
|
|
662
700
|
tasks.each { |t| by_class[t[:orm_class]] = t }
|
|
663
701
|
ordered_classes = _topo_sort_models(tasks.map { |t| t[:orm_class] })
|
|
@@ -675,7 +713,8 @@ module Tina4
|
|
|
675
713
|
overrides: task[:overrides] || {},
|
|
676
714
|
clear: false,
|
|
677
715
|
seed: task[:seed],
|
|
678
|
-
strict: strict
|
|
716
|
+
strict: strict,
|
|
717
|
+
idempotent: idempotent
|
|
679
718
|
)
|
|
680
719
|
end
|
|
681
720
|
|
|
@@ -847,24 +886,34 @@ module Tina4
|
|
|
847
886
|
end
|
|
848
887
|
|
|
849
888
|
# P4c — when a generated/static value's Ruby type clearly mismatches the
|
|
850
|
-
# target column's field type, LOG a warning (never hard-fail).
|
|
851
|
-
#
|
|
852
|
-
#
|
|
889
|
+
# target column's field type, LOG a warning (never hard-fail). A :boolean
|
|
890
|
+
# field expects a native true/false (SEED-RUBY-QUIRKS: FakeData#boolean
|
|
891
|
+
# returns one, not the old 0/1 Integer) — checked separately since Ruby has
|
|
892
|
+
# no single Boolean class to key a type map by.
|
|
853
893
|
def self._validate_types(fields, attrs, model_name)
|
|
854
|
-
expected = { integer: Integer, float: Float
|
|
894
|
+
expected = { integer: Integer, float: Float }
|
|
855
895
|
attrs.each do |name, value|
|
|
856
896
|
next if value.nil?
|
|
857
897
|
|
|
858
898
|
field = fields[name]
|
|
859
899
|
next if field.nil?
|
|
860
900
|
|
|
901
|
+
if field[:type] == :boolean
|
|
902
|
+
unless value == true || value == false
|
|
903
|
+
Tina4::Log.warning(
|
|
904
|
+
"Seeder: #{model_name}.#{name} expected boolean but generated " \
|
|
905
|
+
"#{value.class} (#{value.inspect}) — inserting anyway"
|
|
906
|
+
)
|
|
907
|
+
end
|
|
908
|
+
next
|
|
909
|
+
end
|
|
910
|
+
|
|
861
911
|
want = expected[field[:type]]
|
|
862
912
|
next if want.nil?
|
|
863
913
|
|
|
864
914
|
# A Float landing in an :integer column (or vice-versa) is the suspicious
|
|
865
915
|
# case; everything that is_a? the expected numeric is fine.
|
|
866
916
|
next if value.is_a?(want)
|
|
867
|
-
next if want == Integer && value.is_a?(Numeric) && field[:type] == :boolean
|
|
868
917
|
|
|
869
918
|
Tina4::Log.warning(
|
|
870
919
|
"Seeder: #{model_name}.#{name} expected #{want} but generated " \
|
data/lib/tina4/shutdown.rb
CHANGED
|
@@ -73,6 +73,10 @@ module Tina4
|
|
|
73
73
|
release_resources
|
|
74
74
|
|
|
75
75
|
Tina4::Log.info("Shutdown complete")
|
|
76
|
+
# Graceful shutdown owns the final call to reset() (Decision 24 /
|
|
77
|
+
# LOG-I02): flush+close owned sinks AFTER the shutdown record above,
|
|
78
|
+
# exactly once.
|
|
79
|
+
Tina4::Log.reset
|
|
76
80
|
@shutdown_complete = true
|
|
77
81
|
return if drained
|
|
78
82
|
|