tina4ruby 3.13.149 → 3.13.151
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 +26 -0
- data/lib/tina4/auth.rb +3 -3
- data/lib/tina4/cli.rb +1 -1
- data/lib/tina4/crud.rb +7 -7
- data/lib/tina4/dev_admin.rb +14 -0
- data/lib/tina4/drivers/mongodb_driver.rb +48 -13
- data/lib/tina4/drivers/mssql_driver.rb +6 -1
- data/lib/tina4/form_parser.rb +38 -4
- data/lib/tina4/frond.rb +151 -20
- data/lib/tina4/mcp.rb +36 -12
- data/lib/tina4/metrics.rb +5 -1
- data/lib/tina4/middleware.rb +10 -1
- data/lib/tina4/plan.rb +3 -3
- data/lib/tina4/point.rb +1 -1
- data/lib/tina4/project_index.rb +1 -1
- data/lib/tina4/public/bundled-component-provenance.json +2 -2
- data/lib/tina4/public/js/tina4-dev-admin-split.html +202 -0
- data/lib/tina4/public/js/tina4-dev-admin.min.js +147 -138
- data/lib/tina4/rack_app.rb +4 -1
- data/lib/tina4/realtime/s3_client.rb +240 -0
- data/lib/tina4/realtime/s3_storage.rb +15 -28
- data/lib/tina4/realtime/storage.rb +4 -5
- data/lib/tina4/realtime.rb +2 -1
- data/lib/tina4/request.rb +41 -3
- data/lib/tina4/response_cache.rb +9 -1
- data/lib/tina4/router.rb +1 -1
- data/lib/tina4/sql_translator.rb +5 -3
- data/lib/tina4/template.rb +32 -17
- data/lib/tina4/templates/crud/form.twig +5 -5
- data/lib/tina4/templates/crud/table.twig +2 -2
- data/lib/tina4/templates/errors/302.twig +4 -3
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4/webserver.rb +1 -1
- metadata +4 -2
data/lib/tina4/rack_app.rb
CHANGED
|
@@ -108,6 +108,8 @@ module Tina4
|
|
|
108
108
|
# value. Cleared in `ensure`, exactly like the request id (an overlapping
|
|
109
109
|
# request on another thread never shares it).
|
|
110
110
|
Tina4::Csp.set_current_nonce(Tina4::Csp.generate_nonce)
|
|
111
|
+
# form_token() binds each token to this request's session (CSRF).
|
|
112
|
+
Tina4::Frond.request_env = env
|
|
111
113
|
|
|
112
114
|
begin
|
|
113
115
|
result = dispatch_pipeline(env)
|
|
@@ -121,6 +123,7 @@ module Tina4
|
|
|
121
123
|
# nonce has the same lifetime.
|
|
122
124
|
Tina4::Log.clear_request_id
|
|
123
125
|
Tina4::Csp.clear_current_nonce
|
|
126
|
+
Tina4::Frond.request_env = nil
|
|
124
127
|
end
|
|
125
128
|
end
|
|
126
129
|
|
|
@@ -1247,7 +1250,7 @@ module Tina4
|
|
|
1247
1250
|
# token in the body, auth token in the session) is still authenticated.
|
|
1248
1251
|
candidates = []
|
|
1249
1252
|
auth_header = env["HTTP_AUTHORIZATION"] || ""
|
|
1250
|
-
candidates << [Regexp.last_match(1), :header] if auth_header =~ /\ABearer\s
|
|
1253
|
+
candidates << [Regexp.last_match(1), :header] if auth_header =~ /\ABearer\s++(.+)\z/i
|
|
1251
1254
|
form_token = _extract_form_token(_read_rack_body(env), env)
|
|
1252
1255
|
candidates << [form_token, :body] if form_token && !form_token.empty?
|
|
1253
1256
|
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# Copyright (c) 2026 Code Infinity
|
|
3
|
+
# SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
# This Source Code Form is subject to the terms of the Mozilla Public
|
|
5
|
+
# License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
6
|
+
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
7
|
+
|
|
8
|
+
require "net/http"
|
|
9
|
+
require "uri"
|
|
10
|
+
require "openssl"
|
|
11
|
+
require "digest"
|
|
12
|
+
require "time"
|
|
13
|
+
|
|
14
|
+
module Tina4
|
|
15
|
+
module Realtime
|
|
16
|
+
# An S3 request returned a non-2xx status; +status+ holds the HTTP code.
|
|
17
|
+
class S3Error < StandardError
|
|
18
|
+
attr_reader :status
|
|
19
|
+
attr_accessor :retry_after
|
|
20
|
+
|
|
21
|
+
def initialize(status, message)
|
|
22
|
+
super(message)
|
|
23
|
+
@status = status
|
|
24
|
+
@retry_after = nil
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# The client cannot sign a request: no access key or secret was given.
|
|
29
|
+
class S3ConfigError < StandardError; end
|
|
30
|
+
|
|
31
|
+
# Zero-dependency S3 client (AWS Signature Version 4) on the Ruby standard
|
|
32
|
+
# library. Covers exactly what the realtime {S3Storage} backend needs — PUT,
|
|
33
|
+
# GET, DELETE, HEAD, and a presigned GET URL — so the +s3+ backend no longer
|
|
34
|
+
# needs the aws-sdk-s3 gem.
|
|
35
|
+
#
|
|
36
|
+
# Path-style addressing (<endpoint>/<bucket>/<key>), which S3-compatible
|
|
37
|
+
# servers (MinIO, Garage) require and AWS accepts; with no endpoint it
|
|
38
|
+
# targets https://s3.<region>.amazonaws.com. SigV4 is HMAC-SHA256 canonical
|
|
39
|
+
# signing — the same primitive Tina4's JWT already uses (stdlib OpenSSL);
|
|
40
|
+
# nothing here is reinvented crypto. Mirrors the Python master, proven
|
|
41
|
+
# against AWS's published SigV4 test vector.
|
|
42
|
+
class S3Client
|
|
43
|
+
ALGORITHM = "AWS4-HMAC-SHA256"
|
|
44
|
+
SERVICE = "s3"
|
|
45
|
+
# Retry policy, as for the AI client (ADR-0053): HTTP 429, any 5xx (S3's
|
|
46
|
+
# SlowDown is a 503) and a dropped connection are sent again, a bounded
|
|
47
|
+
# number of times, honouring Retry-After. Every operation here is idempotent.
|
|
48
|
+
MAX_RETRIES = 2
|
|
49
|
+
BACKOFF_BASE = 0.5 # seconds; retry n waits up to base * 2**n, jittered
|
|
50
|
+
BACKOFF_CAP = 20.0
|
|
51
|
+
# A connection refused, reset, dropped or timed out: nothing was answered.
|
|
52
|
+
RETRYABLE_NETWORK = [
|
|
53
|
+
Errno::ECONNREFUSED, Errno::ECONNRESET, Errno::EPIPE, EOFError,
|
|
54
|
+
SocketError, IOError, Net::OpenTimeout, Net::ReadTimeout, Timeout::Error
|
|
55
|
+
].freeze
|
|
56
|
+
|
|
57
|
+
def initialize(endpoint: nil, access_key: "", secret_key: "", region: "us-east-1", timeout: 30)
|
|
58
|
+
@region = region && !region.empty? ? region : "us-east-1"
|
|
59
|
+
@access_key = access_key || ""
|
|
60
|
+
@secret_key = secret_key || ""
|
|
61
|
+
@timeout = timeout
|
|
62
|
+
base = endpoint && !endpoint.empty? ? endpoint : "https://s3.#{@region}.amazonaws.com"
|
|
63
|
+
base = base.delete_suffix("/") while base.end_with?("/") # strip trailing slashes without a regex
|
|
64
|
+
@scheme = base[%r{\A([a-z][a-z0-9+.\-]*)://}i, 1] || "https"
|
|
65
|
+
authority = base.sub(%r{\A[a-z][a-z0-9+.\-]*://}i, "")
|
|
66
|
+
# Split authority (host[:port], the Host header + signed `host:`) from a
|
|
67
|
+
# path an endpoint carries (https://host/s3 -> "/s3"), which is kept in
|
|
68
|
+
# front of every request path and in what is signed.
|
|
69
|
+
slash = authority.index("/")
|
|
70
|
+
@host = slash ? authority[0...slash] : authority
|
|
71
|
+
@prefix = slash ? authority[slash..].delete_suffix("/") : ""
|
|
72
|
+
@prefix = @prefix.delete_suffix("/") while @prefix.end_with?("/")
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# -- public API (the subset S3Storage uses) ------------------------------
|
|
76
|
+
|
|
77
|
+
def put_object(bucket, key, data, content_type = "application/octet-stream")
|
|
78
|
+
request("PUT", canonical_uri(bucket, key), data, { "content-type" => content_type })
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def get_object(bucket, key)
|
|
82
|
+
request("GET", canonical_uri(bucket, key), "")[1]
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def delete_object(bucket, key)
|
|
86
|
+
request("DELETE", canonical_uri(bucket, key), "")
|
|
87
|
+
nil
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def head_object(bucket, key)
|
|
91
|
+
request("HEAD", canonical_uri(bucket, key), "")
|
|
92
|
+
nil
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Create the bucket (path-style PUT /<bucket>); an already-owned bucket (409) is success.
|
|
96
|
+
def create_bucket(bucket)
|
|
97
|
+
request("PUT", "#{@prefix}/#{self.class.uri_encode(bucket, true)}", "")
|
|
98
|
+
nil
|
|
99
|
+
rescue S3Error => e
|
|
100
|
+
raise unless e.status == 409
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# A URL that fetches the object directly, signed via SigV4 query params.
|
|
104
|
+
def presigned_get_url(bucket, key, expires: 3600)
|
|
105
|
+
require_credentials
|
|
106
|
+
now = Time.now.utc
|
|
107
|
+
amz_date = now.strftime("%Y%m%dT%H%M%SZ")
|
|
108
|
+
datestamp = now.strftime("%Y%m%d")
|
|
109
|
+
uri = canonical_uri(bucket, key)
|
|
110
|
+
scope = "#{datestamp}/#{@region}/#{SERVICE}/aws4_request"
|
|
111
|
+
query = {
|
|
112
|
+
"X-Amz-Algorithm" => ALGORITHM,
|
|
113
|
+
"X-Amz-Credential" => "#{@access_key}/#{scope}",
|
|
114
|
+
"X-Amz-Date" => amz_date,
|
|
115
|
+
"X-Amz-Expires" => expires.to_s,
|
|
116
|
+
"X-Amz-SignedHeaders" => "host"
|
|
117
|
+
}
|
|
118
|
+
canonical_qs = canonical_query(query)
|
|
119
|
+
canonical_request = ["GET", uri, canonical_qs, "host:#{@host}\n", "host", "UNSIGNED-PAYLOAD"].join("\n")
|
|
120
|
+
signature = sign(canonical_request, amz_date, datestamp, scope)
|
|
121
|
+
"#{@scheme}://#{@host}#{uri}?#{canonical_qs}&X-Amz-Signature=#{signature}"
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# SigV4 canonical encoding: keep the RFC 3986 unreserved set literal; keep
|
|
125
|
+
# "/" in a path, percent-encode it in a query; space -> %20.
|
|
126
|
+
def self.uri_encode(value, is_path)
|
|
127
|
+
value.to_s.b.each_byte.map do |byte|
|
|
128
|
+
ch = byte.chr
|
|
129
|
+
if ch.match?(/[A-Za-z0-9\-_.~]/) || (is_path && ch == "/")
|
|
130
|
+
ch
|
|
131
|
+
else
|
|
132
|
+
format("%%%02X", byte)
|
|
133
|
+
end
|
|
134
|
+
end.join
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
private
|
|
138
|
+
|
|
139
|
+
# Signing with an empty key only earns a 403 from the server, which says
|
|
140
|
+
# nothing about the cause. Name the settings instead, before sending.
|
|
141
|
+
def require_credentials
|
|
142
|
+
return unless @access_key.empty? || @secret_key.empty?
|
|
143
|
+
|
|
144
|
+
raise S3ConfigError, "S3 credentials are not set: set TINA4_STORAGE_KEY and TINA4_STORAGE_SECRET"
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Seconds from a Retry-After header in its delta-seconds form, else nil.
|
|
148
|
+
def retry_after_seconds(raw)
|
|
149
|
+
return nil if raw.nil?
|
|
150
|
+
|
|
151
|
+
value = Float(raw)
|
|
152
|
+
value.negative? ? 0.0 : value
|
|
153
|
+
rescue ArgumentError, TypeError
|
|
154
|
+
nil
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def canonical_uri(bucket, key)
|
|
158
|
+
"#{@prefix}/#{self.class.uri_encode(bucket, true)}/#{self.class.uri_encode(key, true)}"
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def canonical_query(query)
|
|
162
|
+
query.sort.map { |k, v| "#{self.class.uri_encode(k, false)}=#{self.class.uri_encode(v, false)}" }.join("&")
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def signing_key(datestamp)
|
|
166
|
+
k_date = OpenSSL::HMAC.digest("SHA256", "AWS4#{@secret_key}", datestamp)
|
|
167
|
+
k_region = OpenSSL::HMAC.digest("SHA256", k_date, @region)
|
|
168
|
+
k_service = OpenSSL::HMAC.digest("SHA256", k_region, SERVICE)
|
|
169
|
+
OpenSSL::HMAC.digest("SHA256", k_service, "aws4_request")
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def sign(canonical_request, amz_date, datestamp, scope)
|
|
173
|
+
string_to_sign = [ALGORITHM, amz_date, scope, Digest::SHA256.hexdigest(canonical_request)].join("\n")
|
|
174
|
+
OpenSSL::HMAC.hexdigest("SHA256", signing_key(datestamp), string_to_sign)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def request(method, canonical_uri, body, extra_headers = {})
|
|
178
|
+
require_credentials
|
|
179
|
+
attempt = 0
|
|
180
|
+
loop do
|
|
181
|
+
last = attempt == MAX_RETRIES
|
|
182
|
+
delay = nil
|
|
183
|
+
begin
|
|
184
|
+
# Signed afresh each attempt: x-amz-date moves on.
|
|
185
|
+
return send_once(method, canonical_uri, body, extra_headers)
|
|
186
|
+
rescue S3Error => e
|
|
187
|
+
raise if last || !(e.status == 429 || e.status >= 500)
|
|
188
|
+
|
|
189
|
+
delay = e.retry_after
|
|
190
|
+
rescue *RETRYABLE_NETWORK
|
|
191
|
+
raise if last
|
|
192
|
+
end
|
|
193
|
+
delay ||= rand * (BACKOFF_BASE * (2**attempt))
|
|
194
|
+
sleep([delay, BACKOFF_CAP].min)
|
|
195
|
+
attempt += 1
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def send_once(method, canonical_uri, body, extra_headers = {})
|
|
200
|
+
now = Time.now.utc
|
|
201
|
+
amz_date = now.strftime("%Y%m%dT%H%M%SZ")
|
|
202
|
+
datestamp = now.strftime("%Y%m%d")
|
|
203
|
+
payload_hash = Digest::SHA256.hexdigest(body || "")
|
|
204
|
+
|
|
205
|
+
signed = { "host" => @host, "x-amz-content-sha256" => payload_hash, "x-amz-date" => amz_date }
|
|
206
|
+
canonical_headers = signed.sort.map { |k, v| "#{k}:#{v}\n" }.join
|
|
207
|
+
signed_headers = signed.keys.sort.join(";")
|
|
208
|
+
canonical_request = [method, canonical_uri, "", canonical_headers, signed_headers, payload_hash].join("\n")
|
|
209
|
+
scope = "#{datestamp}/#{@region}/#{SERVICE}/aws4_request"
|
|
210
|
+
signature = sign(canonical_request, amz_date, datestamp, scope)
|
|
211
|
+
authorization = "#{ALGORITHM} Credential=#{@access_key}/#{scope}, " \
|
|
212
|
+
"SignedHeaders=#{signed_headers}, Signature=#{signature}"
|
|
213
|
+
|
|
214
|
+
uri = URI("#{@scheme}://#{@host}#{canonical_uri}")
|
|
215
|
+
klass = { "PUT" => Net::HTTP::Put, "GET" => Net::HTTP::Get,
|
|
216
|
+
"DELETE" => Net::HTTP::Delete, "HEAD" => Net::HTTP::Head }.fetch(method)
|
|
217
|
+
req = klass.new(uri)
|
|
218
|
+
req["Authorization"] = authorization
|
|
219
|
+
req["x-amz-content-sha256"] = payload_hash
|
|
220
|
+
req["x-amz-date"] = amz_date
|
|
221
|
+
extra_headers.each { |name, value| req[name] = value }
|
|
222
|
+
req.body = body if %w[PUT POST].include?(method)
|
|
223
|
+
|
|
224
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
225
|
+
http.use_ssl = uri.scheme == "https"
|
|
226
|
+
http.open_timeout = @timeout
|
|
227
|
+
http.read_timeout = @timeout
|
|
228
|
+
response = http.request(req)
|
|
229
|
+
status = response.code.to_i
|
|
230
|
+
if status < 200 || status >= 300
|
|
231
|
+
error = S3Error.new(status, "#{method} #{canonical_uri}: HTTP #{status}")
|
|
232
|
+
error.retry_after = retry_after_seconds(response["retry-after"])
|
|
233
|
+
raise error
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
[status, response.body || ""]
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
end
|
|
@@ -8,55 +8,42 @@
|
|
|
8
8
|
|
|
9
9
|
module Tina4
|
|
10
10
|
module Realtime
|
|
11
|
-
# S3-compatible store (AWS S3, MinIO, ...). Opt-in
|
|
11
|
+
# S3-compatible store (AWS S3, MinIO, Garage, ...). Opt-in, zero dependencies.
|
|
12
12
|
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
13
|
+
# Talks to S3 through Tina4's own stdlib SigV4 client ({S3Client}) — no
|
|
14
|
+
# aws-sdk-s3 gem. Presigned GET URLs let clients fetch large blobs straight
|
|
15
|
+
# from object storage instead of streaming through the app.
|
|
15
16
|
class S3Storage < StorageBackend
|
|
16
17
|
def initialize(endpoint: nil, key: nil, secret: nil, bucket: nil, region: nil)
|
|
17
18
|
super()
|
|
18
|
-
begin
|
|
19
|
-
require "aws-sdk-s3" # optional dependency, loaded lazily
|
|
20
|
-
rescue LoadError
|
|
21
|
-
# Storage.select rescues this and falls back to local; its warning
|
|
22
|
-
# carries this message, so the remedy reaches the operator.
|
|
23
|
-
raise LoadError,
|
|
24
|
-
"The 'aws-sdk-s3' gem is required for S3Storage. Install it with: " \
|
|
25
|
-
"bundle add aws-sdk-s3 (or add gem \"aws-sdk-s3\" to your Gemfile)"
|
|
26
|
-
end
|
|
27
|
-
|
|
28
19
|
@bucket = bucket || ENV["TINA4_STORAGE_BUCKET"]
|
|
29
20
|
raise ArgumentError, "S3Storage requires TINA4_STORAGE_BUCKET" if @bucket.nil? || @bucket.empty?
|
|
30
21
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
ep = endpoint || ENV["TINA4_STORAGE_URL"]
|
|
38
|
-
opts[:endpoint] = ep if ep && !ep.empty?
|
|
39
|
-
@client = Aws::S3::Client.new(**opts)
|
|
22
|
+
@client = S3Client.new(
|
|
23
|
+
endpoint: endpoint || ENV["TINA4_STORAGE_URL"],
|
|
24
|
+
access_key: key || ENV["TINA4_STORAGE_KEY"] || "",
|
|
25
|
+
secret_key: secret || ENV["TINA4_STORAGE_SECRET"] || "",
|
|
26
|
+
region: region || ENV["TINA4_STORAGE_REGION"] || "us-east-1"
|
|
27
|
+
)
|
|
40
28
|
end
|
|
41
29
|
|
|
42
30
|
def put(key, data, mime = "application/octet-stream")
|
|
43
|
-
@client.put_object(
|
|
31
|
+
@client.put_object(@bucket, key, data, mime)
|
|
44
32
|
nil
|
|
45
33
|
end
|
|
46
34
|
|
|
47
35
|
def get(key)
|
|
48
|
-
@client.get_object(
|
|
36
|
+
@client.get_object(@bucket, key)
|
|
49
37
|
rescue StandardError
|
|
50
38
|
nil
|
|
51
39
|
end
|
|
52
40
|
|
|
53
41
|
def url(key, ttl = 3600)
|
|
54
|
-
|
|
55
|
-
.presigned_url(:get_object, bucket: @bucket, key: key, expires_in: ttl)
|
|
42
|
+
@client.presigned_get_url(@bucket, key, expires: ttl)
|
|
56
43
|
end
|
|
57
44
|
|
|
58
45
|
def delete(key)
|
|
59
|
-
@client.delete_object(
|
|
46
|
+
@client.delete_object(@bucket, key)
|
|
60
47
|
nil
|
|
61
48
|
rescue StandardError => e
|
|
62
49
|
Tina4::Log.error("S3Storage delete failed for #{key}: #{e.message}")
|
|
@@ -64,7 +51,7 @@ module Tina4
|
|
|
64
51
|
end
|
|
65
52
|
|
|
66
53
|
def exists?(key)
|
|
67
|
-
@client.head_object(
|
|
54
|
+
@client.head_object(@bucket, key)
|
|
68
55
|
true
|
|
69
56
|
rescue StandardError
|
|
70
57
|
false
|
|
@@ -39,11 +39,10 @@ module Tina4
|
|
|
39
39
|
if name == "s3"
|
|
40
40
|
begin
|
|
41
41
|
return S3Storage.new
|
|
42
|
-
#
|
|
43
|
-
#
|
|
44
|
-
#
|
|
45
|
-
|
|
46
|
-
rescue StandardError, LoadError => e
|
|
42
|
+
# S3Storage is zero-dependency now (Tina4's own SigV4 client, no gem),
|
|
43
|
+
# so the only construction failure is an incomplete config, e.g. a
|
|
44
|
+
# missing bucket (ArgumentError) — degrade to local rather than crash.
|
|
45
|
+
rescue StandardError => e
|
|
47
46
|
Tina4::Log.warning(
|
|
48
47
|
"realtime files: S3 storage unavailable (#{e.message}); " \
|
|
49
48
|
"falling back to local filesystem storage."
|
data/lib/tina4/realtime.rb
CHANGED
|
@@ -18,6 +18,7 @@ require_relative "realtime/message"
|
|
|
18
18
|
require_relative "realtime/attachment"
|
|
19
19
|
require_relative "realtime/storage_backend"
|
|
20
20
|
require_relative "realtime/local_storage"
|
|
21
|
+
require_relative "realtime/s3_client"
|
|
21
22
|
require_relative "realtime/s3_storage"
|
|
22
23
|
require_relative "realtime/storage"
|
|
23
24
|
require_relative "parse_json"
|
|
@@ -80,7 +81,7 @@ module Tina4
|
|
|
80
81
|
# @param features [Array<String>] any of "calls", "chat", "files"
|
|
81
82
|
def mount(prefix: "", authorize: nil, storage: nil, features: nil)
|
|
82
83
|
features = features || ["calls"]
|
|
83
|
-
p = prefix.to_s.gsub(%r{\A
|
|
84
|
+
p = prefix.to_s.gsub(%r{\A/++|/++\z}, "")
|
|
84
85
|
p = p.empty? ? "" : "/#{p}"
|
|
85
86
|
@authorize = authorize
|
|
86
87
|
@storage = storage
|
data/lib/tina4/request.rb
CHANGED
|
@@ -205,7 +205,7 @@ module Tina4
|
|
|
205
205
|
FileUtils.mkdir_p(target_dir)
|
|
206
206
|
dest = File.join(target_dir, base)
|
|
207
207
|
# Defence in depth: the resolved parent of the destination must be exactly
|
|
208
|
-
# the resolved target dir
|
|
208
|
+
# the resolved target dir.
|
|
209
209
|
real_dir = File.realpath(target_dir)
|
|
210
210
|
real_parent = File.realpath(File.dirname(dest))
|
|
211
211
|
unless real_parent == real_dir
|
|
@@ -213,10 +213,35 @@ module Tina4
|
|
|
213
213
|
end
|
|
214
214
|
|
|
215
215
|
content = file["content"] || file[:content] || ""
|
|
216
|
-
|
|
216
|
+
write_upload(dest, content)
|
|
217
217
|
dest
|
|
218
218
|
end
|
|
219
219
|
|
|
220
|
+
# Never write THROUGH a link already at target/base: a symlink (even a
|
|
221
|
+
# dangling one) leads out of target_dir, and a hard link shares its bytes
|
|
222
|
+
# with a file elsewhere. Such an entry is removed so the upload gets a file
|
|
223
|
+
# of its own; a plain file is rewritten in place and keeps its mode.
|
|
224
|
+
def self.write_upload(dest, content)
|
|
225
|
+
File.unlink(dest) if File.symlink?(dest)
|
|
226
|
+
flags = File::WRONLY | File::CREAT | File::BINARY | (defined?(File::NOFOLLOW) ? File::NOFOLLOW : 0)
|
|
227
|
+
# 0o600: a newly created upload is owner-only, never group/other readable
|
|
228
|
+
# or writable (CWE-732). The mode applies only when CREAT makes the file;
|
|
229
|
+
# a plain file rewritten in place keeps its own mode, per the note above.
|
|
230
|
+
io = File.open(dest, flags, 0o600)
|
|
231
|
+
if io.stat.nlink > 1
|
|
232
|
+
io.close
|
|
233
|
+
File.unlink(dest)
|
|
234
|
+
io = File.open(dest, flags | File::EXCL, 0o600)
|
|
235
|
+
end
|
|
236
|
+
begin
|
|
237
|
+
io.truncate(0)
|
|
238
|
+
io.write(content)
|
|
239
|
+
ensure
|
|
240
|
+
io.close
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
private_class_method :write_upload
|
|
244
|
+
|
|
220
245
|
def initialize(env, path_params = {})
|
|
221
246
|
@env = env
|
|
222
247
|
@method = env["REQUEST_METHOD"]
|
|
@@ -563,7 +588,7 @@ module Tina4
|
|
|
563
588
|
body_raw
|
|
564
589
|
end
|
|
565
590
|
elsif @content_type.include?("application/x-www-form-urlencoded")
|
|
566
|
-
|
|
591
|
+
parse_form_body(body_raw)
|
|
567
592
|
elsif @content_type.include?("multipart/form-data")
|
|
568
593
|
# Form fields from the parsed multipart body (Tina4::FormParser).
|
|
569
594
|
# Files are handled separately by extract_files.
|
|
@@ -594,6 +619,19 @@ module Tina4
|
|
|
594
619
|
result
|
|
595
620
|
end
|
|
596
621
|
|
|
622
|
+
# An urlencoded body, nested the way a multipart form is
|
|
623
|
+
# (`tags[]=a&tags[]=b` -> {"tags" => ["a", "b"]}), so a form arrives in one
|
|
624
|
+
# shape however it was encoded. A field whose name conflicts with an earlier
|
|
625
|
+
# one (`a=1&a[b]=2`) keeps its flat key. The query string stays flat:
|
|
626
|
+
# AutoCrud reads `filter[...]` keys from it.
|
|
627
|
+
def parse_form_body(body)
|
|
628
|
+
pairs = body.split("&").map do |pair|
|
|
629
|
+
key, value = pair.split("=", 2)
|
|
630
|
+
[URI.decode_www_form_component(key.to_s), URI.decode_www_form_component(value.to_s)]
|
|
631
|
+
end
|
|
632
|
+
Tina4::FormParser.nest(pairs, lenient: true)
|
|
633
|
+
end
|
|
634
|
+
|
|
597
635
|
def extract_files
|
|
598
636
|
result = {}
|
|
599
637
|
return result unless @content_type.include?("multipart/form-data")
|
data/lib/tina4/response_cache.rb
CHANGED
|
@@ -502,7 +502,8 @@ module Tina4
|
|
|
502
502
|
# Authorization, and Tina4's own session mechanism IS a cookie. Because
|
|
503
503
|
# the key is method + URL, storing such a response replays one signed-in
|
|
504
504
|
# user's page to whoever asks for that URL next; a response that installs
|
|
505
|
-
# a session (Set-Cookie) is per-user by
|
|
505
|
+
# a session (Set-Cookie, or a session about to add one) is per-user by
|
|
506
|
+
# construction for the same reason.
|
|
506
507
|
#
|
|
507
508
|
# Both cookie cases stay cacheable when the response opts in explicitly with
|
|
508
509
|
# a shared-cache directive, which is how a genuinely public page served to
|
|
@@ -517,6 +518,13 @@ module Tina4
|
|
|
517
518
|
return shared_cache_allowed?(response) unless header_value(request, "Authorization").nil?
|
|
518
519
|
return shared_cache_allowed?(response) unless header_value(request, "Cookie").nil?
|
|
519
520
|
return shared_cache_allowed?(response) unless header_value(response, "Set-Cookie").nil?
|
|
521
|
+
# The session cookie is added after this check, so a response that is
|
|
522
|
+
# about to install one carries no Set-Cookie yet: a request that came
|
|
523
|
+
# without a cookie and leaves its session holding data (a form token
|
|
524
|
+
# bound to it marks it) is that case. Read the session only if the
|
|
525
|
+
# request already opened one; asking for it would open one.
|
|
526
|
+
session = request.instance_variable_get(:@session) if request.instance_variable_defined?(:@session)
|
|
527
|
+
return shared_cache_allowed?(response) if session.respond_to?(:fresh?) && !session.fresh?
|
|
520
528
|
|
|
521
529
|
true
|
|
522
530
|
end
|
data/lib/tina4/router.rb
CHANGED
|
@@ -724,7 +724,7 @@ module Tina4
|
|
|
724
724
|
# trailing slash on a prefix could leave "/api//users").
|
|
725
725
|
def join_group_path(prefix, path)
|
|
726
726
|
full = "#{prefix}/#{path.sub(%r{\A/+}, '')}"
|
|
727
|
-
full = "/#{full.gsub(%r{\A
|
|
727
|
+
full = "/#{full.gsub(%r{\A/++|/++\z}, '')}"
|
|
728
728
|
full.gsub(%r{/+}, "/")
|
|
729
729
|
end
|
|
730
730
|
|
data/lib/tina4/sql_translator.rb
CHANGED
|
@@ -316,11 +316,11 @@ module Tina4
|
|
|
316
316
|
sql
|
|
317
317
|
.gsub(/\bBIGINT\s+PRIMARY\s+KEY\s+AUTOINCREMENT\b/i, "BIGSERIAL PRIMARY KEY")
|
|
318
318
|
.gsub(/\bINTEGER\s+PRIMARY\s+KEY\s+AUTOINCREMENT\b/i, "SERIAL PRIMARY KEY")
|
|
319
|
-
.gsub(/\s
|
|
319
|
+
.gsub(/\s*+\bAUTOINCREMENT\b/i, "")
|
|
320
320
|
when "mssql"
|
|
321
321
|
sql.gsub(/AUTOINCREMENT/i, "IDENTITY(1,1)")
|
|
322
322
|
when "firebird"
|
|
323
|
-
sql.gsub(/\s
|
|
323
|
+
sql.gsub(/\s*+AUTOINCREMENT\b/i, "")
|
|
324
324
|
else
|
|
325
325
|
sql
|
|
326
326
|
end
|
|
@@ -490,7 +490,9 @@ module Tina4
|
|
|
490
490
|
"mariadb" => "mysql"
|
|
491
491
|
}.freeze
|
|
492
492
|
|
|
493
|
-
|
|
493
|
+
# ReDoS-safe: the table ref is bounded by non-space at both ends (\S(?:.*?\S)?)
|
|
494
|
+
# so the `\s+` before VALUES can't overlap it the way `.+?\s+` did.
|
|
495
|
+
INSERT_VALUES = /\A\s*INSERT\s+INTO\s+\S(?:.*?\S)?\s+VALUES\s*\(([^()]*)\)\s*\z/im
|
|
494
496
|
|
|
495
497
|
# Engines whose last_insert_id reports the FIRST generated id of a multi-row
|
|
496
498
|
# INSERT rather than the last. Verified live against MySQL.
|
data/lib/tina4/template.rb
CHANGED
|
@@ -232,7 +232,10 @@ module Tina4
|
|
|
232
232
|
"nl2br" => ->(v) { v.to_s.gsub("\n", "<br>") },
|
|
233
233
|
"number_format" => ->(v, d) { format("%.#{d || 0}f", v.to_f) },
|
|
234
234
|
"raw" => ->(v) { v },
|
|
235
|
-
|
|
235
|
+
# Fixed-point strip: a single gsub leaves a tag that only surfaces once the
|
|
236
|
+
# surrounding one is removed (`<scr<script>ipt>` -> `<script>`). Repeat
|
|
237
|
+
# until nothing more matches (incomplete multi-character sanitization).
|
|
238
|
+
"striptags" => ->(v) { s = v.to_s; loop { t = s.gsub(/<[^>]+>/, ""); break if t == s; s = t }; s },
|
|
236
239
|
"sort" => ->(v) { v.respond_to?(:sort) ? v.sort : v },
|
|
237
240
|
"keys" => ->(v) { v.respond_to?(:keys) ? v.keys : [] },
|
|
238
241
|
"values" => ->(v) { v.respond_to?(:values) ? v.values : [v] },
|
|
@@ -272,8 +275,10 @@ module Tina4
|
|
|
272
275
|
content = process_conditionals(content)
|
|
273
276
|
content = process_set(content)
|
|
274
277
|
content = process_expressions(content)
|
|
275
|
-
|
|
276
|
-
|
|
278
|
+
# ReDoS-safe: a tempered token ([^%] | % not before }) walks to the first
|
|
279
|
+
# %} in one pass — no lazy `.*?` re-scanning each `{%` start (that was O(n^2)).
|
|
280
|
+
content = content.gsub(/\{%(?:[^%]|%(?!\}))*%\}/m, "")
|
|
281
|
+
content = content.gsub(/\{#(?:[^#]|#(?!\}))*#\}/m, "")
|
|
277
282
|
content
|
|
278
283
|
end
|
|
279
284
|
|
|
@@ -295,7 +300,9 @@ module Tina4
|
|
|
295
300
|
private
|
|
296
301
|
|
|
297
302
|
def process_extends(content)
|
|
298
|
-
|
|
303
|
+
# ReDoS-safe: a negated class ([^"']*) for the quoted path can't backtrack
|
|
304
|
+
# across the closing quote the way a lazy (.+?) does.
|
|
305
|
+
if content =~ /\{%\s*extends\s+["']([^"']*)["']\s*%\}/
|
|
299
306
|
parent_path = Regexp.last_match(1)
|
|
300
307
|
full_parent = resolve_template(parent_path)
|
|
301
308
|
if full_parent && File.exist?(full_parent)
|
|
@@ -358,7 +365,7 @@ module Tina4
|
|
|
358
365
|
# grandparent, blocks are merged (child overrides parent) and the
|
|
359
366
|
# chain is followed recursively.
|
|
360
367
|
def render_with_blocks(parent_source, child_blocks)
|
|
361
|
-
extends_re = /\A\s*\{%\s*extends\s+["'](
|
|
368
|
+
extends_re = /\A\s*\{%\s*extends\s+["']([^"']*)["']\s*%\}/
|
|
362
369
|
|
|
363
370
|
# Multi-level: if the parent itself extends another template, recurse
|
|
364
371
|
if parent_source =~ extends_re
|
|
@@ -375,7 +382,7 @@ module Tina4
|
|
|
375
382
|
|
|
376
383
|
# Resolve nested blocks: if a block value contains {% block inner %}
|
|
377
384
|
# tags, replace them with merged_blocks values too
|
|
378
|
-
block_re = /\{%[-\s]*block\s+(\w+)\s*-?%\}(
|
|
385
|
+
block_re = /\{%[-\s]*block\s+(\w+)\s*-?%\}((?:[^{]|\{(?!%[-\s]*endblock\s*-?%\}))*)\{%[-\s]*endblock\s*-?%\}/m
|
|
379
386
|
changed = true
|
|
380
387
|
while changed
|
|
381
388
|
changed = false
|
|
@@ -398,7 +405,7 @@ module Tina4
|
|
|
398
405
|
end
|
|
399
406
|
|
|
400
407
|
# Leaf parent (no extends) — resolve blocks and render
|
|
401
|
-
parent_source.gsub(/\{%[-\s]*block\s+(\w+)\s*-?%\}(
|
|
408
|
+
parent_source.gsub(/\{%[-\s]*block\s+(\w+)\s*-?%\}((?:[^{]|\{(?!%[-\s]*endblock\s*-?%\}))*)\{%[-\s]*endblock\s*-?%\}/m) do
|
|
402
409
|
name = Regexp.last_match(1)
|
|
403
410
|
default_body = Regexp.last_match(2)
|
|
404
411
|
child_blocks[name] || default_body
|
|
@@ -406,7 +413,7 @@ module Tina4
|
|
|
406
413
|
end
|
|
407
414
|
|
|
408
415
|
def process_blocks(content)
|
|
409
|
-
content.gsub(/\{%\s*block\s+(\w+)\s*%\}(
|
|
416
|
+
content.gsub(/\{%\s*block\s+(\w+)\s*%\}((?:[^{]|\{(?!%\s*endblock\s*%\}))*)\{%\s*endblock\s*%\}/m) do
|
|
410
417
|
name = Regexp.last_match(1)
|
|
411
418
|
default_body = Regexp.last_match(2)
|
|
412
419
|
@blocks[name] || default_body
|
|
@@ -414,7 +421,9 @@ module Tina4
|
|
|
414
421
|
end
|
|
415
422
|
|
|
416
423
|
def process_includes(content)
|
|
417
|
-
|
|
424
|
+
# ReDoS-safe: negated class for the quoted path, and the (unused) `with`
|
|
425
|
+
# clause is consumed by a tempered token instead of a lazy (.+?)\s* pair.
|
|
426
|
+
content.gsub(/\{%\s*include\s+["']([^"']*)["'](?:\s+with\s++(?:[^%]|%(?!\}))*+)?\s*+%\}/) do
|
|
418
427
|
inc_path = Regexp.last_match(1)
|
|
419
428
|
full_path = resolve_template(inc_path)
|
|
420
429
|
if full_path && File.exist?(full_path)
|
|
@@ -430,10 +439,10 @@ module Tina4
|
|
|
430
439
|
max_depth = 10
|
|
431
440
|
depth = 0
|
|
432
441
|
while content =~ /\{%\s*for\s+/ && depth < max_depth
|
|
433
|
-
content = content.gsub(/\{%\s*for\s+(\w+)(?:\s*,\s*(\w+))?\s+in\s
|
|
442
|
+
content = content.gsub(/\{%\s*for\s+(\w+)(?:\s*,\s*(\w+))?\s+in\s++((?:[^%]|%(?!\}))++)%\}((?:[^{]|\{(?!%\s*endfor\s*%\}))*+)\{%\s*endfor\s*%\}/m) do
|
|
434
443
|
key_or_val = Regexp.last_match(1)
|
|
435
444
|
val_name = Regexp.last_match(2)
|
|
436
|
-
collection_expr = Regexp.last_match(3)
|
|
445
|
+
collection_expr = Regexp.last_match(3).strip
|
|
437
446
|
body = Regexp.last_match(4)
|
|
438
447
|
collection = evaluate_expression(collection_expr)
|
|
439
448
|
output = +""
|
|
@@ -475,9 +484,11 @@ module Tina4
|
|
|
475
484
|
depth = 0
|
|
476
485
|
while content =~ /\{%\s*if\s+/ && depth < max_depth
|
|
477
486
|
content = content.gsub(
|
|
478
|
-
|
|
487
|
+
# ReDoS-safe: tempered tokens — condition walks to %}, bodies stop at
|
|
488
|
+
# the first else/endif, so no lazy (.+?)/\s* overlap to backtrack on.
|
|
489
|
+
/\{%\s*if\s++((?:[^%]|%(?!\}))++)%\}((?:[^{]|\{(?!%\s*(?:else|endif)\s*%\}))*+)(?:\{%\s*else\s*%\}((?:[^{]|\{(?!%\s*endif\s*%\}))*+))?\{%\s*endif\s*%\}/m
|
|
479
490
|
) do
|
|
480
|
-
condition = Regexp.last_match(1)
|
|
491
|
+
condition = Regexp.last_match(1).strip
|
|
481
492
|
true_body = Regexp.last_match(2)
|
|
482
493
|
false_body = Regexp.last_match(3) || ""
|
|
483
494
|
|
|
@@ -511,17 +522,21 @@ module Tina4
|
|
|
511
522
|
end
|
|
512
523
|
|
|
513
524
|
def process_set(content)
|
|
514
|
-
content.gsub(/\{%\s*set\s+(\w+)\s
|
|
525
|
+
content.gsub(/\{%\s*set\s+(\w+)\s*=((?:[^%]|%(?!\}))+)%\}/) do
|
|
515
526
|
var_name = Regexp.last_match(1)
|
|
516
|
-
expr = Regexp.last_match(2)
|
|
527
|
+
expr = Regexp.last_match(2).strip
|
|
517
528
|
@context[var_name] = evaluate_expression(expr)
|
|
518
529
|
""
|
|
519
530
|
end
|
|
520
531
|
end
|
|
521
532
|
|
|
522
533
|
def process_expressions(content)
|
|
523
|
-
|
|
524
|
-
|
|
534
|
+
# ReDoS-safe: the old /\{\{\s*(.+?)\s*\}\}/ let the surrounding \s* AND the
|
|
535
|
+
# lazy (.+?) both match spaces — two ways to assign the whitespace, so
|
|
536
|
+
# polynomial backtracking. A tempered token ([^}] | } not before }) walks
|
|
537
|
+
# to the first }} in one deterministic pass; strip the whitespace in code.
|
|
538
|
+
content.gsub(/\{\{((?:[^}]|\}(?!\}))++)\}\}/) do
|
|
539
|
+
expr = Regexp.last_match(1).strip
|
|
525
540
|
evaluate_piped_expression(expr).to_s
|
|
526
541
|
end
|
|
527
542
|
end
|