tina4ruby 3.13.150 → 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 +16 -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/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/middleware.rb +10 -1
- data/lib/tina4/plan.rb +2 -2
- data/lib/tina4/point.rb +1 -1
- data/lib/tina4/project_index.rb +1 -1
- 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 +3 -2
data/lib/tina4/middleware.rb
CHANGED
|
@@ -783,6 +783,7 @@ module Tina4
|
|
|
783
783
|
# TINA4_CSP — Content-Security-Policy (default: "default-src 'self'")
|
|
784
784
|
# TINA4_REFERRER_POLICY — Referrer-Policy (default: strict-origin-when-cross-origin)
|
|
785
785
|
# TINA4_PERMISSIONS_POLICY — Permissions-Policy (default: camera=(), microphone=(), geolocation=())
|
|
786
|
+
# TINA4_COOP — Cross-Origin-Opener-Policy (default: same-origin-allow-popups)
|
|
786
787
|
class SecurityHeadersMiddleware
|
|
787
788
|
class << self
|
|
788
789
|
# Register this middleware in the default chain (secure-by-default).
|
|
@@ -815,7 +816,15 @@ module Tina4
|
|
|
815
816
|
["Content-Security-Policy", Tina4::Csp.resolve_csp_header(Tina4::Csp.current_nonce)],
|
|
816
817
|
["Referrer-Policy", ENV["TINA4_REFERRER_POLICY"] || "strict-origin-when-cross-origin"],
|
|
817
818
|
["X-XSS-Protection", "0"],
|
|
818
|
-
["Permissions-Policy", ENV["TINA4_PERMISSIONS_POLICY"] || "camera=(), microphone=(), geolocation=()"]
|
|
819
|
+
["Permissions-Policy", ENV["TINA4_PERMISSIONS_POLICY"] || "camera=(), microphone=(), geolocation=()"],
|
|
820
|
+
# Cross-Origin-Opener-Policy severs the window-reference link to a
|
|
821
|
+
# cross-origin opener (closing reverse-tab-nabbing + cross-window
|
|
822
|
+
# XS-Leaks that X-Frame-Options does not). Default same-origin-allow-popups:
|
|
823
|
+
# isolates the page from its opener while leaving popups IT opens able to
|
|
824
|
+
# post back, so OAuth/payment popups keep working. TINA4_COOP=same-origin
|
|
825
|
+
# for full isolation, unsafe-none to opt out. Secure-context only (https
|
|
826
|
+
# and http://localhost), inert elsewhere.
|
|
827
|
+
["Cross-Origin-Opener-Policy", ENV["TINA4_COOP"] || "same-origin-allow-popups"]
|
|
819
828
|
]
|
|
820
829
|
end
|
|
821
830
|
|
data/lib/tina4/plan.rb
CHANGED
|
@@ -53,7 +53,7 @@ module Tina4
|
|
|
53
53
|
end
|
|
54
54
|
|
|
55
55
|
def slugify(title)
|
|
56
|
-
slug = title.to_s.strip.downcase.gsub(/[^a-z0-9_\-]+/, "-").gsub(/\A
|
|
56
|
+
slug = title.to_s.strip.downcase.gsub(/[^a-z0-9_\-]+/, "-").gsub(/\A-++|-++\z/, "")
|
|
57
57
|
slug = slug[0, 80]
|
|
58
58
|
slug.empty? ? "plan-#{Time.now.to_i}" : slug
|
|
59
59
|
end
|
|
@@ -69,7 +69,7 @@ module Tina4
|
|
|
69
69
|
section = nil # :steps | :notes | :other | nil
|
|
70
70
|
|
|
71
71
|
lines.each do |raw|
|
|
72
|
-
line = raw.sub(/[[:space:]]
|
|
72
|
+
line = raw.sub(/[[:space:]]++\z/, "")
|
|
73
73
|
if title.empty? && line.start_with?("# ")
|
|
74
74
|
title = line[2..].to_s.strip
|
|
75
75
|
next
|
data/lib/tina4/point.rb
CHANGED
|
@@ -52,7 +52,7 @@ module Tina4
|
|
|
52
52
|
return from_geojson(value, srid) if value.is_a?(Hash)
|
|
53
53
|
if value.is_a?(String)
|
|
54
54
|
text = value.strip
|
|
55
|
-
match = text.match(/\A(?:SRID\s*=\s*(\d+)\s*;\s*)?POINT\s*(?:Z|M|ZM)?\s*\(\s*([-+0-9.eE]+)\s
|
|
55
|
+
match = text.match(/\A(?:SRID\s*=\s*(\d+)\s*;\s*)?POINT\s*(?:Z|M|ZM)?\s*\(\s*([-+0-9.eE]+)\s++([-+0-9.eE]+)(?:\s++[-+0-9.eE]+)*+\s*+\)\z/i)
|
|
56
56
|
return new(match[2], match[3], srid: match[1] ? match[1].to_i : srid) if match
|
|
57
57
|
raw = [text].pack("H*") if text.length >= 42 && text.length.even? && text.match?(/\A[0-9a-f]+\z/i)
|
|
58
58
|
raw ||= value.b if [0, 1].include?(value.getbyte(0))
|
data/lib/tina4/project_index.rb
CHANGED
|
@@ -71,7 +71,7 @@ module Tina4
|
|
|
71
71
|
text.scan(/^\s*def\s+(self\.)?([A-Za-z_][\w!?=]*)/) { |m| out["symbols"] << m[1] }
|
|
72
72
|
text.scan(/^\s*require(?:_relative)?\s+['"]([^'"]+)['"]/) { |m| out["imports"] << m[0] }
|
|
73
73
|
# Tina4.get "/path" OR Tina4::Router.get("/path") OR get "/path" do
|
|
74
|
-
route_re = /(?:Tina4(?:::Router)?\.|^\s*)(get|post|put|patch|delete|any|any_method|secure_get|secure_post)\s
|
|
74
|
+
route_re = /(?:Tina4(?:::Router)?\.|^\s*)(get|post|put|patch|delete|any|any_method|secure_get|secure_post)\s*+\(?\s*+['"]([^'"]+)['"]/
|
|
75
75
|
text.scan(route_re) do |meth, path|
|
|
76
76
|
next unless ROUTE_METHODS.include?(meth)
|
|
77
77
|
out["routes"] << { "method" => meth.upcase, "path" => path, "handler" => "" }
|
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.
|