tina4ruby 3.13.137 → 3.13.138

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.
Files changed (160) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +36 -0
  3. data/COMMERCIAL-LICENSE.md +8 -0
  4. data/LICENSE +373 -0
  5. data/LICENSE.txt +373 -21
  6. data/NOTICE +11 -0
  7. data/README.md +11 -7
  8. data/exe/tina4ruby +6 -0
  9. data/lib/tina4/ai.rb +6 -0
  10. data/lib/tina4/ai_client.rb +10 -3
  11. data/lib/tina4/api.rb +57 -7
  12. data/lib/tina4/auth.rb +18 -12
  13. data/lib/tina4/auto_crud.rb +83 -27
  14. data/lib/tina4/background.rb +7 -1
  15. data/lib/tina4/base64.rb +59 -0
  16. data/lib/tina4/cache.rb +6 -0
  17. data/lib/tina4/cache_backends/base_backend.rb +6 -0
  18. data/lib/tina4/cache_backends/database_backend.rb +6 -0
  19. data/lib/tina4/cache_backends/file_backend.rb +6 -0
  20. data/lib/tina4/cache_backends/memcached_backend.rb +6 -0
  21. data/lib/tina4/cache_backends/memory_backend.rb +6 -0
  22. data/lib/tina4/cache_backends/mongo_backend.rb +6 -0
  23. data/lib/tina4/cache_backends/redis_backend.rb +6 -0
  24. data/lib/tina4/cache_backends/valkey_backend.rb +6 -0
  25. data/lib/tina4/cache_backends.rb +6 -0
  26. data/lib/tina4/cli.rb +22 -7
  27. data/lib/tina4/constants.rb +12 -8
  28. data/lib/tina4/container.rb +6 -0
  29. data/lib/tina4/context/chunker.rb +6 -0
  30. data/lib/tina4/context.rb +13 -5
  31. data/lib/tina4/cors.rb +66 -10
  32. data/lib/tina4/crud.rb +78 -9
  33. data/lib/tina4/database/sqlite3_adapter.rb +9 -1
  34. data/lib/tina4/database.rb +260 -44
  35. data/lib/tina4/database_adapter.rb +6 -0
  36. data/lib/tina4/database_result.rb +6 -0
  37. data/lib/tina4/database_url.rb +6 -0
  38. data/lib/tina4/debug.rb +6 -0
  39. data/lib/tina4/dev.rb +6 -0
  40. data/lib/tina4/dev_admin.rb +215 -89
  41. data/lib/tina4/dev_mailbox.rb +6 -1
  42. data/lib/tina4/dispatch_pipeline.rb +86 -1
  43. data/lib/tina4/docs.rb +6 -0
  44. data/lib/tina4/docstore.rb +23 -4
  45. data/lib/tina4/drivers/arango_graph_driver.rb +8 -1
  46. data/lib/tina4/drivers/bolt_graph_driver.rb +6 -0
  47. data/lib/tina4/drivers/firebird_driver.rb +95 -6
  48. data/lib/tina4/drivers/mongodb_driver.rb +6 -0
  49. data/lib/tina4/drivers/mssql_driver.rb +54 -39
  50. data/lib/tina4/drivers/mysql_driver.rb +6 -0
  51. data/lib/tina4/drivers/odbc_driver.rb +6 -0
  52. data/lib/tina4/drivers/postgres_driver.rb +35 -8
  53. data/lib/tina4/drivers/schema_split.rb +6 -0
  54. data/lib/tina4/drivers/sqlite_driver.rb +8 -1
  55. data/lib/tina4/drivers/ultipa_graph_driver.rb +6 -0
  56. data/lib/tina4/env.rb +6 -0
  57. data/lib/tina4/error_overlay.rb +6 -0
  58. data/lib/tina4/events.rb +6 -0
  59. data/lib/tina4/feedback.rb +9 -2
  60. data/lib/tina4/field_types.rb +6 -0
  61. data/lib/tina4/form_parser.rb +241 -0
  62. data/lib/tina4/frond.rb +196 -56
  63. data/lib/tina4/gallery/auth/src/routes/api/gallery_auth.rb +6 -0
  64. data/lib/tina4/gallery/database/src/routes/api/gallery_db.rb +6 -0
  65. data/lib/tina4/gallery/error-overlay/src/routes/api/gallery_crash.rb +6 -0
  66. data/lib/tina4/gallery/orm/src/routes/api/gallery_products.rb +6 -0
  67. data/lib/tina4/gallery/queue/src/routes/api/gallery_queue.rb +6 -0
  68. data/lib/tina4/gallery/rest-api/src/routes/api/gallery_hello.rb +6 -0
  69. data/lib/tina4/gallery/templates/src/routes/gallery_page.rb +6 -0
  70. data/lib/tina4/graph.rb +6 -0
  71. data/lib/tina4/graphql.rb +28 -27
  72. data/lib/tina4/health.rb +6 -0
  73. data/lib/tina4/html_element.rb +6 -0
  74. data/lib/tina4/http_server.rb +690 -0
  75. data/lib/tina4/imap_client.rb +352 -0
  76. data/lib/tina4/import_helper.rb +6 -0
  77. data/lib/tina4/job.rb +6 -0
  78. data/lib/tina4/localization.rb +6 -0
  79. data/lib/tina4/log.rb +6 -0
  80. data/lib/tina4/mail_socket.rb +124 -0
  81. data/lib/tina4/mcp.rb +15 -8
  82. data/lib/tina4/messenger.rb +86 -55
  83. data/lib/tina4/metrics.rb +6 -0
  84. data/lib/tina4/middleware.rb +24 -9
  85. data/lib/tina4/migration.rb +6 -0
  86. data/lib/tina4/model_collection.rb +6 -0
  87. data/lib/tina4/mqtt.rb +9 -2
  88. data/lib/tina4/mqtt_message.rb +6 -0
  89. data/lib/tina4/orm.rb +72 -16
  90. data/lib/tina4/parse_json.rb +25 -0
  91. data/lib/tina4/plan.rb +9 -2
  92. data/lib/tina4/point.rb +6 -0
  93. data/lib/tina4/port_takeover.rb +6 -0
  94. data/lib/tina4/project_index.rb +6 -0
  95. data/lib/tina4/public/THIRD-PARTY-NOTICES.md +55 -0
  96. data/lib/tina4/public/bundled-component-provenance.json +48 -0
  97. data/lib/tina4/public/js/tina4js.min.js +13 -3
  98. data/lib/tina4/public/licenses/swagger-ui-3.10.0-original-notice.txt +11 -0
  99. data/lib/tina4/public/licenses/swagger-ui-5.17.14-NOTICE.txt +2 -0
  100. data/lib/tina4/public/licenses/swagger-ui-Apache-2.0.txt +202 -0
  101. data/lib/tina4/public/licenses/tina4-css-MIT.txt +21 -0
  102. data/lib/tina4/public/licenses/tina4-dev-admin-components.json +240 -0
  103. data/lib/tina4/public/licenses/tina4-dev-admin-third-party.txt +919 -0
  104. data/lib/tina4/public/licenses/tina4-js-MIT.txt +20 -0
  105. data/lib/tina4/push.rb +9 -3
  106. data/lib/tina4/query_builder.rb +6 -0
  107. data/lib/tina4/queue.rb +6 -0
  108. data/lib/tina4/queue_backends/kafka_backend.rb +10 -2
  109. data/lib/tina4/queue_backends/lite_backend.rb +6 -0
  110. data/lib/tina4/queue_backends/mongo_backend.rb +6 -0
  111. data/lib/tina4/queue_backends/rabbitmq_backend.rb +10 -2
  112. data/lib/tina4/rack_app.rb +11 -1
  113. data/lib/tina4/rate_limiter.rb +6 -0
  114. data/lib/tina4/realtime/attachment.rb +6 -0
  115. data/lib/tina4/realtime/channel.rb +6 -0
  116. data/lib/tina4/realtime/channel_member.rb +6 -0
  117. data/lib/tina4/realtime/local_storage.rb +6 -0
  118. data/lib/tina4/realtime/message.rb +6 -0
  119. data/lib/tina4/realtime/s3_storage.rb +6 -0
  120. data/lib/tina4/realtime/storage.rb +6 -0
  121. data/lib/tina4/realtime/storage_backend.rb +6 -0
  122. data/lib/tina4/realtime/workspace.rb +6 -0
  123. data/lib/tina4/realtime.rb +10 -3
  124. data/lib/tina4/request.rb +85 -127
  125. data/lib/tina4/response.rb +71 -9
  126. data/lib/tina4/response_cache.rb +6 -0
  127. data/lib/tina4/router.rb +6 -0
  128. data/lib/tina4/secret_file.rb +34 -0
  129. data/lib/tina4/seeder.rb +9 -3
  130. data/lib/tina4/service.rb +8 -2
  131. data/lib/tina4/service_runner.rb +6 -0
  132. data/lib/tina4/session.rb +6 -0
  133. data/lib/tina4/session_handlers/database_handler.rb +6 -0
  134. data/lib/tina4/session_handlers/file_handler.rb +6 -0
  135. data/lib/tina4/session_handlers/memcached_handler.rb +6 -0
  136. data/lib/tina4/session_handlers/mongo_handler.rb +108 -39
  137. data/lib/tina4/session_handlers/mongo_wire_client.rb +23 -9
  138. data/lib/tina4/session_handlers/redis_handler.rb +6 -0
  139. data/lib/tina4/session_handlers/resp_client.rb +6 -0
  140. data/lib/tina4/session_handlers/valkey_handler.rb +6 -0
  141. data/lib/tina4/shutdown.rb +17 -14
  142. data/lib/tina4/smtp_client.rb +222 -0
  143. data/lib/tina4/sql_translator.rb +93 -0
  144. data/lib/tina4/sqlite3_gem.rb +27 -0
  145. data/lib/tina4/sso.rb +25 -8
  146. data/lib/tina4/swagger.rb +6 -0
  147. data/lib/tina4/template.rb +6 -0
  148. data/lib/tina4/test.rb +8 -2
  149. data/lib/tina4/test_client.rb +8 -1
  150. data/lib/tina4/testing.rb +8 -1
  151. data/lib/tina4/validator.rb +6 -0
  152. data/lib/tina4/version.rb +7 -1
  153. data/lib/tina4/webserver.rb +25 -143
  154. data/lib/tina4/websocket.rb +10 -4
  155. data/lib/tina4/websocket_backplane.rb +19 -2
  156. data/lib/tina4/wsdl.rb +31 -16
  157. data/lib/tina4/xml_parser.rb +259 -0
  158. data/lib/tina4.rb +87 -31
  159. data/lib/tina4ruby.rb +6 -0
  160. metadata +43 -140
data/lib/tina4/env.rb CHANGED
@@ -1,4 +1,10 @@
1
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
+
2
8
  require "digest"
3
9
 
4
10
  module Tina4
@@ -1,4 +1,10 @@
1
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
+
2
8
 
3
9
  # Tina4 Debug — Rich error overlay for development mode.
4
10
  #
data/lib/tina4/events.rb CHANGED
@@ -1,4 +1,10 @@
1
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
+
2
8
 
3
9
  # Tina4 Events — Simple observer pattern for decoupled communication.
4
10
  #
@@ -1,8 +1,15 @@
1
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
+
2
8
 
3
9
  require "json"
4
10
  require "net/http"
5
11
  require "uri"
12
+ require_relative "parse_json"
6
13
 
7
14
  module Tina4
8
15
  # ── Customer feedback widget — server-side plumbing ─────────────────
@@ -170,7 +177,7 @@ module Tina4
170
177
  req.body = JSON.generate(forward_body)
171
178
  resp = Net::HTTP.start(uri.host, uri.port, open_timeout: 5, read_timeout: 60) { |h| h.request(req) }
172
179
  parsed = begin
173
- JSON.parse(resp.body.to_s)
180
+ Tina4.parse_json(resp.body.to_s)
174
181
  rescue JSON::ParserError
175
182
  nil
176
183
  end
@@ -293,7 +300,7 @@ module Tina4
293
300
  input.rewind if input.respond_to?(:rewind)
294
301
  raw = input.read
295
302
  return nil if raw.nil? || raw.empty?
296
- JSON.parse(raw)
303
+ Tina4.parse_json(raw)
297
304
  rescue JSON::ParserError
298
305
  nil
299
306
  end
@@ -1,4 +1,10 @@
1
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
+
2
8
 
3
9
  module Tina4
4
10
  module FieldTypes
@@ -0,0 +1,241 @@
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
+
9
+ require "stringio"
10
+
11
+ module Tina4
12
+ # multipart/form-data parsing, owned by Tina4 (no Rack).
13
+ #
14
+ # Replaces Rack::Request#POST, which Tina4::Request used for multipart bodies
15
+ # until rack stopped being a dependency. Field names nest exactly the way Rack
16
+ # nested them - `user[name]` becomes a hash, `tags[]` an array, `a[][x]` an
17
+ # array of hashes, a repeated plain name keeps its last value - so a Ruby app
18
+ # that posts nested forms sees the same body it always did. spec/form_parser_spec.rb
19
+ # pins every shape against values captured from Rack 3.2.7.
20
+ #
21
+ # Files are parsed in the SAME pass and keyed by field name, each name mapping
22
+ # to a LIST of descriptors so a repeated file field keeps every file
23
+ # (fileupload_contract upload-repeated-field-list).
24
+ #
25
+ # The part delimiter is CRLF + "--" + boundary, as RFC 2046 defines it, so a
26
+ # file whose bytes happen to contain "--boundary" mid-line is not cut short.
27
+ module FormParser
28
+ # A name nested deeper than this is refused rather than recursed into.
29
+ # Rack's QueryParser param_depth_limit, kept for the same reason.
30
+ PARAM_DEPTH_LIMIT = 32
31
+
32
+ # A field name that is both a value and a container (`a=1` then `a[b]=2`)
33
+ # or nests past PARAM_DEPTH_LIMIT. Rack raised here too.
34
+ class ParameterError < StandardError; end
35
+
36
+ # Marks a hash the NESTING built, as opposed to a value that happens to be a
37
+ # hash. Only these may be descended into.
38
+ class NestedParams < Hash; end
39
+ private_constant :NestedParams
40
+
41
+ module_function
42
+
43
+ # The boundary token from a multipart Content-Type, quotes removed; nil when
44
+ # there is none.
45
+ def boundary(content_type)
46
+ content_type.to_s.split(";").each do |part|
47
+ part = part.strip
48
+ next unless part.downcase.start_with?("boundary=")
49
+
50
+ value = part[9..].to_s.strip
51
+ value = value[1...-1] if value.length >= 2 && value.start_with?('"') && value.end_with?('"')
52
+ return value.empty? ? nil : value
53
+ end
54
+ nil
55
+ end
56
+
57
+ # Parse a multipart body.
58
+ #
59
+ # @return [Hash] { fields: Hash (nested, Rack-shaped), files: Hash{name => [descriptor]},
60
+ # error: ParameterError or nil }
61
+ # A descriptor is { filename:, type:, content:, size:, tempfile: } with the
62
+ # RAW client filename - Tina4::Request.save_upload sanitises it on write.
63
+ # A name conflict or over-deep nesting empties the fields and is returned
64
+ # as :error rather than raised, so one bad field name cannot lose the files.
65
+ def parse_multipart(body, content_type)
66
+ token = boundary(content_type)
67
+ fields = NestedParams.new
68
+ files = {}
69
+ return { fields: {}, files: files, error: nil } if token.nil? || body.nil? || body.empty?
70
+
71
+ pairs = []
72
+ each_part(body.b, token) do |headers, content|
73
+ name, filename = disposition_params(headers["content-disposition"].to_s)
74
+ next if name.nil? || name.empty?
75
+
76
+ if filename.nil?
77
+ pairs << [name.dup.force_encoding(Encoding::UTF_8), text_value(content, headers["content-type"])]
78
+ else
79
+ (files[name] ||= []) << {
80
+ filename: filename.dup.force_encoding(Encoding::UTF_8).scrub,
81
+ type: headers["content-type"] || "application/octet-stream",
82
+ content: content,
83
+ size: content.bytesize,
84
+ tempfile: StringIO.new(content)
85
+ }
86
+ end
87
+ end
88
+ # A name conflict voids the FIELDS only (Rack raised for the whole form);
89
+ # the files were collected independently and survive it.
90
+ error = nil
91
+ begin
92
+ pairs.each { |name, value| normalize_params(fields, name, value, 0) }
93
+ rescue ParameterError => e
94
+ error = e
95
+ fields = NestedParams.new
96
+ end
97
+ { fields: to_plain_hash(fields), files: files, error: error }
98
+ end
99
+
100
+ # Yield [headers, content] for every part. headers keys are lowercased.
101
+ def each_part(body, token)
102
+ delimiter = "--#{token}".b
103
+ position = body.index(delimiter)
104
+ return if position.nil?
105
+
106
+ position += delimiter.bytesize
107
+ separator = "\r\n#{delimiter}".b
108
+ while position < body.bytesize
109
+ # "--" straight after a delimiter closes the body.
110
+ break if body.byteslice(position, 2) == "--"
111
+
112
+ position += 2 if body.byteslice(position, 2) == "\r\n"
113
+ header_end = body.index("\r\n\r\n", position)
114
+ break if header_end.nil?
115
+
116
+ next_delimiter = body.index(separator, header_end + 4)
117
+ break if next_delimiter.nil?
118
+
119
+ yield part_headers(body.byteslice(position, header_end - position)),
120
+ body.byteslice(header_end + 4, next_delimiter - header_end - 4)
121
+ position = next_delimiter + separator.bytesize
122
+ end
123
+ end
124
+
125
+ def part_headers(block)
126
+ headers = {}
127
+ block.split("\r\n").each do |line|
128
+ name, value = line.split(":", 2)
129
+ next if value.nil?
130
+
131
+ headers[name.strip.downcase] = value.strip.force_encoding(Encoding::UTF_8).scrub
132
+ end
133
+ headers
134
+ end
135
+
136
+ # name= and filename= from a Content-Disposition value, by PARAMETER (not by
137
+ # a substring match, which found "name=" inside "filename=").
138
+ def disposition_params(disposition)
139
+ params = {}
140
+ disposition.scan(/;\s*([A-Za-z0-9!#$%&'*+.^_`|~-]+)\s*=\s*("(?:[^"\\]|\\.)*"|[^;]*)/) do |key, raw|
141
+ value = raw.strip
142
+ value = value[1...-1].gsub(/\\(.)/, '\1') if value.start_with?('"') && value.end_with?('"') && value.length >= 2
143
+ params[key.downcase] ||= value
144
+ end
145
+ [params["name"], params["filename"]]
146
+ end
147
+
148
+ # A text field is UTF-8 unless the part says `text/plain; charset=...`
149
+ # (Rack's rule). An unknown charset falls back to binary, never raises.
150
+ def text_value(content, part_type)
151
+ encoding = Encoding::UTF_8
152
+ if part_type
153
+ type, *parameters = part_type.split(";").map(&:strip)
154
+ if type.casecmp?("text/plain")
155
+ parameters.each do |parameter|
156
+ key, value = parameter.split("=", 2)
157
+ next unless key&.casecmp?("charset") && value
158
+
159
+ encoding = begin
160
+ Encoding.find(value.delete('"'))
161
+ rescue ArgumentError
162
+ Encoding::BINARY
163
+ end
164
+ end
165
+ end
166
+ end
167
+ content.dup.force_encoding(encoding)
168
+ end
169
+
170
+ # Rack::QueryParser#normalize_params, ported. Returns params.
171
+ def normalize_params(params, name, value, depth)
172
+ raise ParameterError, "field name nests deeper than #{PARAM_DEPTH_LIMIT} levels" if depth >= PARAM_DEPTH_LIMIT
173
+
174
+ if depth.zero?
175
+ start = name.index("[", 1)
176
+ key = start ? name[0, start] : name
177
+ after = start ? name[start..] : ""
178
+ elsif name.start_with?("[]")
179
+ key = "[]"
180
+ after = name[2..]
181
+ elsif name.start_with?("[") && (close = name.index("]", 1))
182
+ key = name[1, close - 1]
183
+ after = name[(close + 1)..]
184
+ else
185
+ key = name
186
+ after = ""
187
+ end
188
+ return if key.empty?
189
+
190
+ if after.empty?
191
+ return [value] if key == "[]" && !depth.zero?
192
+
193
+ params[key] = value
194
+ elsif after == "["
195
+ params[name] = value
196
+ elsif after == "[]"
197
+ params[key] ||= []
198
+ raise ParameterError, "expected Array for `#{key}'" unless params[key].is_a?(Array)
199
+
200
+ params[key] << value
201
+ elsif after.start_with?("[]")
202
+ child_key = after[3, after.length - 4] if after[2] == "[" && after.end_with?("]")
203
+ child_key = after[2..] if child_key.nil? || child_key.empty? || child_key.include?("[") || child_key.include?("]")
204
+ params[key] ||= []
205
+ raise ParameterError, "expected Array for `#{key}'" unless params[key].is_a?(Array)
206
+
207
+ last = params[key].last
208
+ if last.is_a?(NestedParams) && !nested_key?(last, child_key)
209
+ normalize_params(last, child_key, value, depth + 1)
210
+ else
211
+ params[key] << normalize_params(NestedParams.new, child_key, value, depth + 1)
212
+ end
213
+ else
214
+ params[key] ||= NestedParams.new
215
+ raise ParameterError, "expected Hash for `#{key}'" unless params[key].is_a?(NestedParams)
216
+
217
+ params[key] = normalize_params(params[key], after, value, depth + 1)
218
+ end
219
+ params
220
+ end
221
+
222
+ def nested_key?(hash, key)
223
+ return false if key.include?("[]")
224
+
225
+ key.split(/[\[\]]+/).reject(&:empty?).reduce(hash) do |level, part|
226
+ return false unless level.is_a?(NestedParams) && level.key?(part)
227
+
228
+ level[part]
229
+ end
230
+ true
231
+ end
232
+
233
+ def to_plain_hash(value)
234
+ case value
235
+ when Hash then value.each_with_object({}) { |(key, child), plain| plain[key] = to_plain_hash(child) }
236
+ when Array then value.map { |child| to_plain_hash(child) }
237
+ else value
238
+ end
239
+ end
240
+ end
241
+ end
data/lib/tina4/frond.rb CHANGED
@@ -1,4 +1,10 @@
1
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
+
2
8
 
3
9
  # Tina4 Frond Engine -- Lexer, parser, and runtime.
4
10
  # Zero-dependency twig-like template engine.
@@ -8,13 +14,14 @@
8
14
 
9
15
  require "json"
10
16
  require "digest"
11
- require "base64"
12
- require "cgi"
17
+ require_relative "base64"
18
+ require "cgi/escape"
13
19
  require "erb"
14
20
  require "uri"
15
21
  require "date"
16
22
  require "time"
17
23
  require "securerandom"
24
+ require_relative "parse_json"
18
25
 
19
26
  module Tina4
20
27
  # Marker class for strings that should not be auto-escaped in Frond.
@@ -484,6 +491,38 @@ module Tina4
484
491
  # The escaping decision has to ask this rather than read the filter name out
485
492
  # of the source: a denied `raw` that still marked its value safe made the
486
493
  # allow-list entry governing XSS escaping inert.
494
+ # Words in an expression that are not variable references.
495
+ SANDBOX_EXPR_KEYWORDS = %w[true false none null nil and or not in is loop
496
+ if else empty starts ends with matches].freeze
497
+ SANDBOX_IDENT_RE = /(?<![.\w'"])([A-Za-z_][A-Za-z0-9_]*)/
498
+ SANDBOX_STRING_RE = /'[^']*'|"[^"]*"/
499
+ # Methods that must never be invoked through {{ obj.method }} — reflection,
500
+ # metaprogramming and process control (F6/ADR-0077).
501
+ UNSAFE_METHODS = %w[send __send__ public_send instance_eval instance_exec
502
+ eval class methods public_methods private_methods
503
+ instance_variables instance_variable_get
504
+ instance_variable_set define_singleton_method method
505
+ tap then object_id __id__ system exec spawn binding
506
+ singleton_class ancestors superclass constants
507
+ const_get remove_method].to_set.freeze
508
+
509
+ # Sandbox gate for a WHOLE expression (F6/ADR-0077): refuse a dunder walk
510
+ # or a root variable outside the allow-list, in output, conditions, set
511
+ # right-hand sides and for iterables alike.
512
+ def sandbox_expr_ok?(expr)
513
+ return true unless @sandbox
514
+ return false if expr.include?("__")
515
+ return true unless @allowed_vars
516
+
517
+ stripped = expr.gsub(SANDBOX_STRING_RE, "")
518
+ stripped.scan(SANDBOX_IDENT_RE).each do |(ident)|
519
+ next if SANDBOX_EXPR_KEYWORDS.include?(ident.downcase)
520
+ next if @filters.key?(ident)
521
+ return false unless @allowed_vars.include?(ident)
522
+ end
523
+ true
524
+ end
525
+
487
526
  def filter_permitted?(name)
488
527
  return true unless @sandbox && @allowed_filters
489
528
 
@@ -506,6 +545,83 @@ module Tina4
506
545
  str.to_s.gsub(HTML_ESCAPE_RE, HTML_ESCAPE_MAP)
507
546
  end
508
547
 
548
+ # -- Escape strategies (ADR-0077) ------------------------------------
549
+ # Shared by js_escape and e(strategy); byte-identical to the Python
550
+ # master. Twig-compatible.
551
+ JS_SAFE_RE = /[A-Za-z0-9,._]/
552
+ CSS_SAFE_RE = /[A-Za-z0-9]/
553
+ ATTR_SAFE_RE = /[A-Za-z0-9,.\-_]/
554
+
555
+ def self.js_escape_str(value)
556
+ out = +""
557
+ value.to_s.each_char do |ch|
558
+ if ch =~ JS_SAFE_RE
559
+ out << ch
560
+ next
561
+ end
562
+ code = ch.ord
563
+ if code < 0x80
564
+ out << format("\\x%02X", code)
565
+ elsif code <= 0xFFFF
566
+ out << format("\\u%04X", code)
567
+ else
568
+ code -= 0x10000
569
+ out << format("\\u%04X", 0xD800 + (code >> 10))
570
+ out << format("\\u%04X", 0xDC00 + (code & 0x3FF))
571
+ end
572
+ end
573
+ out
574
+ end
575
+
576
+ def self.css_escape(value)
577
+ out = +""
578
+ value.to_s.each_char do |ch|
579
+ out << (ch =~ CSS_SAFE_RE ? ch : format("\\%06X ", ch.ord))
580
+ end
581
+ out
582
+ end
583
+
584
+ def self.html_attr_escape(value)
585
+ out = +""
586
+ value.to_s.each_char do |ch|
587
+ out << (ch =~ ATTR_SAFE_RE ? ch : format("&#x%02X;", ch.ord))
588
+ end
589
+ out
590
+ end
591
+
592
+ # RFC-3986 rawurlencode: unreserved set stays, everything else percent-encoded.
593
+ def self.url_escape(value)
594
+ value.to_s.b.gsub(/[^A-Za-z0-9\-_.~]/) { |c| format("%%%02X", c.ord) }
595
+ end
596
+
597
+ # Dispatch e(strategy)/escape(strategy). An unknown strategy raises, so a
598
+ # typo can never silently pass the value through unescaped.
599
+ def self.escape_strategy(value, strategy = "html")
600
+ s = strategy.to_s.strip.gsub(/\A['"]|['"]\z/, "")
601
+ case s
602
+ when "", "html"
603
+ Tina4::SafeString.new(escape_html(value.to_s))
604
+ when "js"
605
+ Tina4::SafeString.new(js_escape_str(value))
606
+ when "url"
607
+ Tina4::SafeString.new(url_escape(value))
608
+ when "css"
609
+ Tina4::SafeString.new(css_escape(value))
610
+ when "html_attr", "attr"
611
+ Tina4::SafeString.new(html_attr_escape(value))
612
+ else
613
+ raise ArgumentError, "Unknown escape strategy: #{strategy}"
614
+ end
615
+ end
616
+
617
+ # Confine a client-supplied media type to a strict type/subtype grammar
618
+ # (F3/ADR-0077); anything malformed falls back to a safe default.
619
+ MEDIA_TYPE_RE = %r{\A[A-Za-z0-9][A-Za-z0-9!\#$&^_.+-]*/[A-Za-z0-9][A-Za-z0-9!\#$&^_.+-]*\z}
620
+ def self.sanitise_media_type(type)
621
+ t = type.to_s.strip
622
+ t =~ MEDIA_TYPE_RE ? t : "application/octet-stream"
623
+ end
624
+
509
625
  # Serializes a value to compact JSON text that is always valid JSON.
510
626
  #
511
627
  # Never raises and never returns an empty string: a non-finite float becomes
@@ -616,35 +732,54 @@ module Tina4
616
732
  # Tokenizer
617
733
  # -----------------------------------------------------------------------
618
734
 
619
- # Regex to extract {% raw %}...{% endraw %} blocks before tokenizing
620
- RAW_BLOCK_RE = /\{%-?\s*raw\s*-?%\}(.*?)\{%-?\s*endraw\s*-?%\}/m
621
-
735
+ # Walk literal delimiters instead of retrying a whole-template regex at
736
+ # every unmatched opener. This also bounds parsing on Ruby versions that
737
+ # do not memoize regexp backtracking.
622
738
  def tokenize(source)
623
- # 1. Extract raw blocks and replace with placeholders
624
739
  raw_blocks = []
625
- source = source.gsub(RAW_BLOCK_RE) do
626
- idx = raw_blocks.length
627
- raw_blocks << Regexp.last_match(1)
628
- "\x00RAW_#{idx}\x00"
740
+ chunks = []
741
+ copied = 0
742
+ cursor = 0
743
+ raw_start = nil
744
+ while (start = source.index("{%", cursor))
745
+ cursor = start + 2
746
+ match = /\G\{%-?\s*(raw|endraw)\s*-?%\}/.match(source, start)
747
+ next unless match
748
+ finish = match.end(0) - 2
749
+ tag = match[1]
750
+ if tag == "raw" && raw_start.nil?
751
+ raw_start = [start, finish + 2]
752
+ elsif tag == "endraw" && raw_start
753
+ chunks << source[copied...raw_start[0]]
754
+ chunks << "\x00RAW_#{raw_blocks.length}\x00"
755
+ raw_blocks << source[raw_start[1]...start]
756
+ copied = finish + 2
757
+ raw_start = nil
758
+ end
629
759
  end
760
+ chunks << source[copied..]
761
+ source = chunks.join
630
762
 
631
- # 2. Normal tokenization
632
763
  tokens = []
633
764
  pos = 0
634
- source.scan(TOKEN_RE) do
635
- m = Regexp.last_match
636
- start = m.begin(0)
637
- tokens << [TEXT, source[pos...start]] if start > pos
638
-
639
- raw = m[0]
640
- if raw.start_with?("{#")
641
- tokens << [COMMENT, raw]
642
- elsif raw.start_with?("{{")
643
- tokens << [VAR, raw]
644
- elsif raw.start_with?("{%")
645
- tokens << [BLOCK, raw]
765
+ cursor = 0
766
+ missing_close = {}
767
+ closers = { "{%" => "%}", "{{" => "}}", "{#" => "#}" }
768
+ while (match = /\{%|\{\{|\{#/.match(source, cursor))
769
+ start = match.begin(0)
770
+ opener = match[0]
771
+ cursor = start + 2
772
+ next if missing_close[opener]
773
+ finish = source.index(closers.fetch(opener), cursor)
774
+ unless finish
775
+ missing_close[opener] = true
776
+ next
646
777
  end
647
- pos = m.end(0)
778
+ tokens << [TEXT, source[pos...start]] if start > pos
779
+ raw = source[start...(finish + 2)]
780
+ type = { "{#" => COMMENT, "{{" => VAR, "{%" => BLOCK }.fetch(opener)
781
+ tokens << [type, raw]
782
+ pos = cursor = finish + 2
648
783
  end
649
784
  tokens << [TEXT, source[pos..]] if pos < source.length
650
785
 
@@ -1155,6 +1290,10 @@ module Tina4
1155
1290
  end
1156
1291
 
1157
1292
  def eval_var_raw(expr, context)
1293
+ # Sandbox: gate the whole condition/RHS (F6) so a comparison or iterable
1294
+ # cannot leak a blocked variable.
1295
+ return nil if @sandbox && !sandbox_expr_ok?(expr)
1296
+
1158
1297
  var_name, filters = parse_filter_chain(expr)
1159
1298
  value = eval_expr(var_name, context)
1160
1299
  filters.each do |fname, args|
@@ -1232,16 +1371,8 @@ module Tina4
1232
1371
 
1233
1372
  var_name, filters = parse_filter_chain(expr)
1234
1373
 
1235
- # Sandbox: check variable access
1236
- if @sandbox && @allowed_vars
1237
- root_var = @dotted_split_cache[var_name]
1238
- unless root_var
1239
- root_var = var_name.split(".")[0].split("[")[0].strip
1240
- cap_cache(@dotted_split_cache, MEMO_CACHE_MAX)
1241
- @dotted_split_cache[var_name] = root_var
1242
- end
1243
- return "" if !root_var.empty? && !@allowed_vars.include?(root_var) && root_var != "loop"
1244
- end
1374
+ # Sandbox: gate the whole expression (allow-list + dunder block, F6)
1375
+ return "" if @sandbox && !sandbox_expr_ok?(expr)
1245
1376
 
1246
1377
  value = eval_expr(var_name, context)
1247
1378
 
@@ -1283,7 +1414,7 @@ module Tina4
1283
1414
  when "title" then value.to_s.split.map(&:capitalize).join(" ")
1284
1415
  when "string" then value.to_s
1285
1416
  when "int" then value.to_i
1286
- when "escape", "e" then Tina4::SafeString.new(Frond.escape_html(value.to_s))
1417
+ when "escape", "e" then Frond.escape_strategy(value, "html")
1287
1418
  else value
1288
1419
  end
1289
1420
  next
@@ -1296,9 +1427,17 @@ module Tina4
1296
1427
  end
1297
1428
  end
1298
1429
 
1299
- # Auto-escape HTML unless marked safe or SafeString
1300
- if @auto_escape && !is_safe && value.is_a?(String) && !value.is_a?(SafeString)
1301
- value = Frond.escape_html(value)
1430
+ # Auto-escape HTML unless marked safe or SafeString. A plain string is
1431
+ # escaped directly; an Array/Hash/object is escaped on its rendered
1432
+ # string form (F4/ADR-0077) -- before this, only String values were
1433
+ # escaped, so {{ items }} holding markup emitted it raw. nil, booleans
1434
+ # and Numerics carry no HTML-special characters and are left untouched.
1435
+ if @auto_escape && !is_safe && !value.is_a?(SafeString)
1436
+ if value.is_a?(String)
1437
+ value = Frond.escape_html(value)
1438
+ elsif !value.nil? && value != true && value != false && !value.is_a?(Numeric)
1439
+ value = Tina4::SafeString.new(Frond.escape_html(value.to_s))
1440
+ end
1302
1441
  end
1303
1442
 
1304
1443
  value
@@ -2060,8 +2199,10 @@ module Tina4
2060
2199
  end
2061
2200
  idx = idx.to_i if idx.is_a?(Numeric)
2062
2201
  value = idx.is_a?(Integer) ? value[idx] : nil
2063
- elsif value.respond_to?(part.to_sym)
2064
- value = value.send(part.to_sym)
2202
+ elsif !UNSAFE_METHODS.include?(part) && value.respond_to?(part.to_sym)
2203
+ # Only call a method the object publicly exposes, and never a
2204
+ # reflection/metaprogramming method (F6/ADR-0077).
2205
+ value = value.public_send(part.to_sym)
2065
2206
  else
2066
2207
  return nil
2067
2208
  end
@@ -2235,7 +2376,11 @@ module Tina4
2235
2376
  i += 1
2236
2377
  end
2237
2378
 
2238
- iterable = eval_expr(iterable_expr, context)
2379
+ iterable = if @sandbox && !sandbox_expr_ok?(iterable_expr)
2380
+ nil
2381
+ else
2382
+ eval_expr(iterable_expr, context)
2383
+ end
2239
2384
 
2240
2385
  if iterable.nil? || (iterable.respond_to?(:empty?) && iterable.empty?)
2241
2386
  if else_tokens.any?
@@ -2852,25 +2997,25 @@ module Tina4
2852
2997
  "striptags" => ->(v, *_a) { v.to_s.gsub(STRIPTAGS_RE, "") },
2853
2998
 
2854
2999
  # -- Encoding --
2855
- "escape" => ->(v, *_a) { Tina4::SafeString.new(Frond.escape_html(v.to_s)) },
2856
- "e" => ->(v, *_a) { Tina4::SafeString.new(Frond.escape_html(v.to_s)) },
3000
+ "escape" => ->(v, *a) { Frond.escape_strategy(v, a[0] || "html") },
3001
+ "e" => ->(v, *a) { Frond.escape_strategy(v, a[0] || "html") },
2857
3002
  "raw" => ->(v, *_a) { v },
2858
3003
  "safe" => ->(v, *_a) { v },
2859
3004
  # Frond.json_safe, never a bare JSON.generate: generate RAISES on an
2860
3005
  # Infinity/NaN float, and the old `rescue v.to_s` answered that raise with
2861
3006
  # Ruby inspect output that no JSON.parse will read. See Frond.json_safe.
2862
3007
  "json_encode" => ->(v, *_a) { Frond.json_safe(v) },
2863
- "json_decode" => ->(v, *_a) { v.is_a?(String) ? (JSON.parse(v) rescue v) : v },
2864
- "base64_encode" => ->(v, *_a) { Base64.strict_encode64(v.is_a?(String) ? v : v.to_s) },
2865
- "base64encode" => ->(v, *_a) { Base64.strict_encode64(v.is_a?(String) ? v : v.to_s) },
2866
- "base64_decode" => ->(v, *_a) { Base64.decode64(v.to_s) },
2867
- "base64decode" => ->(v, *_a) { Base64.decode64(v.to_s) },
3008
+ "json_decode" => ->(v, *_a) { v.is_a?(String) ? (Tina4.parse_json(v) rescue v) : v },
3009
+ "base64_encode" => ->(v, *_a) { Tina4::Base64.strict_encode64(v.is_a?(String) ? v : v.to_s) },
3010
+ "base64encode" => ->(v, *_a) { Tina4::Base64.strict_encode64(v.is_a?(String) ? v : v.to_s) },
3011
+ "base64_decode" => ->(v, *_a) { Tina4::Base64.decode64(v.to_s) },
3012
+ "base64decode" => ->(v, *_a) { Tina4::Base64.decode64(v.to_s) },
2868
3013
  "data_uri" => ->(v, *_a) {
2869
3014
  if v.is_a?(Hash)
2870
- ct = v[:type] || v["type"] || "application/octet-stream"
3015
+ ct = Frond.sanitise_media_type(v[:type] || v["type"] || "application/octet-stream")
2871
3016
  raw = v[:content] || v["content"] || ""
2872
3017
  raw = raw.respond_to?(:read) ? raw.read : raw
2873
- "data:#{ct};base64,#{Base64.strict_encode64(raw.to_s)}"
3018
+ Tina4::SafeString.new("data:#{ct};base64,#{Tina4::Base64.strict_encode64(raw.to_s)}")
2874
3019
  else
2875
3020
  v.to_s
2876
3021
  end
@@ -2884,12 +3029,7 @@ module Tina4
2884
3029
  # broke byte-parity for the one filter whose whole job is a wire format.
2885
3030
  "to_json" => ->(v, *_a) { Frond.json_safe(v) },
2886
3031
  "tojson" => ->(v, *_a) { Frond.json_safe(v) },
2887
- "js_escape" => ->(v, *_a) {
2888
- Tina4::SafeString.new(
2889
- v.to_s.gsub("\\", "\\\\").gsub("'", "\\'").gsub('"', '\\"')
2890
- .gsub("\n", "\\n").gsub("\r", "\\r").gsub("\t", "\\t")
2891
- )
2892
- },
3032
+ "js_escape" => ->(v, *_a) { Tina4::SafeString.new(Frond.js_escape_str(v)) },
2893
3033
 
2894
3034
  # -- Hashing --
2895
3035
  "md5" => ->(v, *_a) { Digest::MD5.hexdigest(v.to_s) },