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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +36 -0
- data/COMMERCIAL-LICENSE.md +8 -0
- data/LICENSE +373 -0
- data/LICENSE.txt +373 -21
- data/NOTICE +11 -0
- data/README.md +11 -7
- data/exe/tina4ruby +6 -0
- data/lib/tina4/ai.rb +6 -0
- data/lib/tina4/ai_client.rb +10 -3
- data/lib/tina4/api.rb +57 -7
- data/lib/tina4/auth.rb +18 -12
- data/lib/tina4/auto_crud.rb +83 -27
- data/lib/tina4/background.rb +7 -1
- data/lib/tina4/base64.rb +59 -0
- data/lib/tina4/cache.rb +6 -0
- data/lib/tina4/cache_backends/base_backend.rb +6 -0
- data/lib/tina4/cache_backends/database_backend.rb +6 -0
- data/lib/tina4/cache_backends/file_backend.rb +6 -0
- data/lib/tina4/cache_backends/memcached_backend.rb +6 -0
- data/lib/tina4/cache_backends/memory_backend.rb +6 -0
- data/lib/tina4/cache_backends/mongo_backend.rb +6 -0
- data/lib/tina4/cache_backends/redis_backend.rb +6 -0
- data/lib/tina4/cache_backends/valkey_backend.rb +6 -0
- data/lib/tina4/cache_backends.rb +6 -0
- data/lib/tina4/cli.rb +22 -7
- data/lib/tina4/constants.rb +12 -8
- data/lib/tina4/container.rb +6 -0
- data/lib/tina4/context/chunker.rb +6 -0
- data/lib/tina4/context.rb +13 -5
- data/lib/tina4/cors.rb +66 -10
- data/lib/tina4/crud.rb +78 -9
- data/lib/tina4/database/sqlite3_adapter.rb +9 -1
- data/lib/tina4/database.rb +260 -44
- data/lib/tina4/database_adapter.rb +6 -0
- data/lib/tina4/database_result.rb +6 -0
- data/lib/tina4/database_url.rb +6 -0
- data/lib/tina4/debug.rb +6 -0
- data/lib/tina4/dev.rb +6 -0
- data/lib/tina4/dev_admin.rb +215 -89
- data/lib/tina4/dev_mailbox.rb +6 -1
- data/lib/tina4/dispatch_pipeline.rb +86 -1
- data/lib/tina4/docs.rb +6 -0
- data/lib/tina4/docstore.rb +23 -4
- data/lib/tina4/drivers/arango_graph_driver.rb +8 -1
- data/lib/tina4/drivers/bolt_graph_driver.rb +6 -0
- data/lib/tina4/drivers/firebird_driver.rb +95 -6
- data/lib/tina4/drivers/mongodb_driver.rb +6 -0
- data/lib/tina4/drivers/mssql_driver.rb +54 -39
- data/lib/tina4/drivers/mysql_driver.rb +6 -0
- data/lib/tina4/drivers/odbc_driver.rb +6 -0
- data/lib/tina4/drivers/postgres_driver.rb +35 -8
- data/lib/tina4/drivers/schema_split.rb +6 -0
- data/lib/tina4/drivers/sqlite_driver.rb +8 -1
- data/lib/tina4/drivers/ultipa_graph_driver.rb +6 -0
- data/lib/tina4/env.rb +6 -0
- data/lib/tina4/error_overlay.rb +6 -0
- data/lib/tina4/events.rb +6 -0
- data/lib/tina4/feedback.rb +9 -2
- data/lib/tina4/field_types.rb +6 -0
- data/lib/tina4/form_parser.rb +241 -0
- data/lib/tina4/frond.rb +196 -56
- data/lib/tina4/gallery/auth/src/routes/api/gallery_auth.rb +6 -0
- data/lib/tina4/gallery/database/src/routes/api/gallery_db.rb +6 -0
- data/lib/tina4/gallery/error-overlay/src/routes/api/gallery_crash.rb +6 -0
- data/lib/tina4/gallery/orm/src/routes/api/gallery_products.rb +6 -0
- data/lib/tina4/gallery/queue/src/routes/api/gallery_queue.rb +6 -0
- data/lib/tina4/gallery/rest-api/src/routes/api/gallery_hello.rb +6 -0
- data/lib/tina4/gallery/templates/src/routes/gallery_page.rb +6 -0
- data/lib/tina4/graph.rb +6 -0
- data/lib/tina4/graphql.rb +28 -27
- data/lib/tina4/health.rb +6 -0
- data/lib/tina4/html_element.rb +6 -0
- data/lib/tina4/http_server.rb +690 -0
- data/lib/tina4/imap_client.rb +352 -0
- data/lib/tina4/import_helper.rb +6 -0
- data/lib/tina4/job.rb +6 -0
- data/lib/tina4/localization.rb +6 -0
- data/lib/tina4/log.rb +6 -0
- data/lib/tina4/mail_socket.rb +124 -0
- data/lib/tina4/mcp.rb +15 -8
- data/lib/tina4/messenger.rb +86 -55
- data/lib/tina4/metrics.rb +6 -0
- data/lib/tina4/middleware.rb +24 -9
- data/lib/tina4/migration.rb +6 -0
- data/lib/tina4/model_collection.rb +6 -0
- data/lib/tina4/mqtt.rb +9 -2
- data/lib/tina4/mqtt_message.rb +6 -0
- data/lib/tina4/orm.rb +72 -16
- data/lib/tina4/parse_json.rb +25 -0
- data/lib/tina4/plan.rb +9 -2
- data/lib/tina4/point.rb +6 -0
- data/lib/tina4/port_takeover.rb +6 -0
- data/lib/tina4/project_index.rb +6 -0
- data/lib/tina4/public/THIRD-PARTY-NOTICES.md +55 -0
- data/lib/tina4/public/bundled-component-provenance.json +48 -0
- data/lib/tina4/public/js/tina4js.min.js +13 -3
- data/lib/tina4/public/licenses/swagger-ui-3.10.0-original-notice.txt +11 -0
- data/lib/tina4/public/licenses/swagger-ui-5.17.14-NOTICE.txt +2 -0
- data/lib/tina4/public/licenses/swagger-ui-Apache-2.0.txt +202 -0
- data/lib/tina4/public/licenses/tina4-css-MIT.txt +21 -0
- data/lib/tina4/public/licenses/tina4-dev-admin-components.json +240 -0
- data/lib/tina4/public/licenses/tina4-dev-admin-third-party.txt +919 -0
- data/lib/tina4/public/licenses/tina4-js-MIT.txt +20 -0
- data/lib/tina4/push.rb +9 -3
- data/lib/tina4/query_builder.rb +6 -0
- data/lib/tina4/queue.rb +6 -0
- data/lib/tina4/queue_backends/kafka_backend.rb +10 -2
- data/lib/tina4/queue_backends/lite_backend.rb +6 -0
- data/lib/tina4/queue_backends/mongo_backend.rb +6 -0
- data/lib/tina4/queue_backends/rabbitmq_backend.rb +10 -2
- data/lib/tina4/rack_app.rb +11 -1
- data/lib/tina4/rate_limiter.rb +6 -0
- data/lib/tina4/realtime/attachment.rb +6 -0
- data/lib/tina4/realtime/channel.rb +6 -0
- data/lib/tina4/realtime/channel_member.rb +6 -0
- data/lib/tina4/realtime/local_storage.rb +6 -0
- data/lib/tina4/realtime/message.rb +6 -0
- data/lib/tina4/realtime/s3_storage.rb +6 -0
- data/lib/tina4/realtime/storage.rb +6 -0
- data/lib/tina4/realtime/storage_backend.rb +6 -0
- data/lib/tina4/realtime/workspace.rb +6 -0
- data/lib/tina4/realtime.rb +10 -3
- data/lib/tina4/request.rb +85 -127
- data/lib/tina4/response.rb +71 -9
- data/lib/tina4/response_cache.rb +6 -0
- data/lib/tina4/router.rb +6 -0
- data/lib/tina4/secret_file.rb +34 -0
- data/lib/tina4/seeder.rb +9 -3
- data/lib/tina4/service.rb +8 -2
- data/lib/tina4/service_runner.rb +6 -0
- data/lib/tina4/session.rb +6 -0
- data/lib/tina4/session_handlers/database_handler.rb +6 -0
- data/lib/tina4/session_handlers/file_handler.rb +6 -0
- data/lib/tina4/session_handlers/memcached_handler.rb +6 -0
- data/lib/tina4/session_handlers/mongo_handler.rb +108 -39
- data/lib/tina4/session_handlers/mongo_wire_client.rb +23 -9
- data/lib/tina4/session_handlers/redis_handler.rb +6 -0
- data/lib/tina4/session_handlers/resp_client.rb +6 -0
- data/lib/tina4/session_handlers/valkey_handler.rb +6 -0
- data/lib/tina4/shutdown.rb +17 -14
- data/lib/tina4/smtp_client.rb +222 -0
- data/lib/tina4/sql_translator.rb +93 -0
- data/lib/tina4/sqlite3_gem.rb +27 -0
- data/lib/tina4/sso.rb +25 -8
- data/lib/tina4/swagger.rb +6 -0
- data/lib/tina4/template.rb +6 -0
- data/lib/tina4/test.rb +8 -2
- data/lib/tina4/test_client.rb +8 -1
- data/lib/tina4/testing.rb +8 -1
- data/lib/tina4/validator.rb +6 -0
- data/lib/tina4/version.rb +7 -1
- data/lib/tina4/webserver.rb +25 -143
- data/lib/tina4/websocket.rb +10 -4
- data/lib/tina4/websocket_backplane.rb +19 -2
- data/lib/tina4/wsdl.rb +31 -16
- data/lib/tina4/xml_parser.rb +259 -0
- data/lib/tina4.rb +87 -31
- data/lib/tina4ruby.rb +6 -0
- 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
|
data/lib/tina4/error_overlay.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 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
|
#
|
data/lib/tina4/feedback.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
303
|
+
Tina4.parse_json(raw)
|
|
297
304
|
rescue JSON::ParserError
|
|
298
305
|
nil
|
|
299
306
|
end
|
data/lib/tina4/field_types.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
|
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
|
-
|
|
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
|
-
#
|
|
620
|
-
|
|
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
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
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
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
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
|
|
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:
|
|
1236
|
-
if @sandbox &&
|
|
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
|
|
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
|
-
|
|
1301
|
-
|
|
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
|
-
|
|
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 =
|
|
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, *
|
|
2856
|
-
"e" => ->(v, *
|
|
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) ? (
|
|
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) },
|