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.
@@ -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-+|-+\z/, "")
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:]]+\z/, "")
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+([-+0-9.eE]+)(?:\s+[-+0-9.eE]+)*\s*\)\z/i)
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))
@@ -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*\(?\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" => "" }
@@ -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+(.+)\z/i
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; needs the aws-sdk-s3 gem.
11
+ # S3-compatible store (AWS S3, MinIO, Garage, ...). Opt-in, zero dependencies.
12
12
  #
13
- # Presigned GET URLs let clients fetch large blobs straight from object
14
- # storage instead of streaming through the app.
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
- opts = {
32
- access_key_id: key || ENV["TINA4_STORAGE_KEY"],
33
- secret_access_key: secret || ENV["TINA4_STORAGE_SECRET"],
34
- region: region || ENV["TINA4_STORAGE_REGION"] || "us-east-1",
35
- force_path_style: true
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(bucket: @bucket, key: key, body: data, content_type: mime)
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(bucket: @bucket, key: key).body.read
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
- Aws::S3::Presigner.new(client: @client)
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(bucket: @bucket, key: key)
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(bucket: @bucket, key: key)
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
- # LoadError (missing aws-sdk-s3 gem) is a ScriptError, NOT a
43
- # StandardError, so it must be rescued explicitly or the "graceful
44
- # fallback when the driver is absent" contract breaks (the whole mount
45
- # would crash instead of degrading to local).
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."
@@ -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/+|/+\z}, "")
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 (guards a pre-existing symlink at target/base).
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
- File.binwrite(dest, content)
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
- parse_query_to_hash(body_raw)
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")
@@ -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 construction for the same reason.
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/+|/+\z}, '')}"
727
+ full = "/#{full.gsub(%r{\A/++|/++\z}, '')}"
728
728
  full.gsub(%r{/+}, "/")
729
729
  end
730
730
 
@@ -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*\bAUTOINCREMENT\b/i, "")
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*AUTOINCREMENT\b/i, "")
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
- INSERT_VALUES = /\A\s*INSERT\s+INTO\s+.+?\s+VALUES\s*\(([^()]*)\)\s*\z/im
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.