tina4ruby 3.13.137 → 3.13.139
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 +40 -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 +74 -8
- data/lib/tina4/auth.rb +108 -26
- 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 +14 -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 +22 -1
- 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 +9 -1
- data/lib/tina4/cli.rb +27 -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 +222 -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 +16 -1
- data/lib/tina4/drivers/ultipa_graph_driver.rb +6 -0
- data/lib/tina4/env.rb +14 -6
- 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 +200 -58
- 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 +134 -35
- 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 +21 -2
- 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 +18 -9
- data/lib/tina4/messenger.rb +86 -55
- data/lib/tina4/metrics.rb +6 -0
- data/lib/tina4/middleware.rb +27 -10
- 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 +22 -4
- data/lib/tina4/query_builder.rb +6 -0
- data/lib/tina4/queue.rb +6 -0
- data/lib/tina4/queue_backends/kafka_backend.rb +18 -2
- data/lib/tina4/queue_backends/lite_backend.rb +18 -0
- data/lib/tina4/queue_backends/mongo_backend.rb +18 -0
- data/lib/tina4/queue_backends/rabbitmq_backend.rb +18 -2
- data/lib/tina4/rack_app.rb +62 -50
- 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 +15 -1
- 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 +90 -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 +42 -12
- 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 +52 -12
- data/lib/tina4/ssrf.rb +148 -0
- 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 +170 -20
- data/lib/tina4/websocket_backplane.rb +25 -2
- data/lib/tina4/wsdl.rb +54 -18
- data/lib/tina4/xml_parser.rb +268 -0
- data/lib/tina4.rb +98 -33
- data/lib/tina4ruby.rb +6 -0
- metadata +44 -140
data/lib/tina4/api.rb
CHANGED
|
@@ -1,11 +1,19 @@
|
|
|
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 "net/http"
|
|
4
10
|
require "openssl"
|
|
5
11
|
require "uri"
|
|
6
12
|
require "json"
|
|
7
|
-
|
|
13
|
+
require_relative "base64"
|
|
8
14
|
require "securerandom"
|
|
15
|
+
require_relative "parse_json"
|
|
16
|
+
require_relative "ssrf"
|
|
9
17
|
|
|
10
18
|
module Tina4
|
|
11
19
|
# Statuses that warrant an automatic retry when max_retries > 0: rate-limit
|
|
@@ -101,7 +109,7 @@ module Tina4
|
|
|
101
109
|
def initialize(base_url, headers: {}, timeout: 30,
|
|
102
110
|
bearer_token: nil, username: nil, password: nil,
|
|
103
111
|
verify_ssl: nil, max_retries: 0, retry_backoff: 0.5,
|
|
104
|
-
transport: nil, cookies: false)
|
|
112
|
+
transport: nil, cookies: false, allow_hosts: nil)
|
|
105
113
|
if !transport.nil? && !transport.respond_to?(:call)
|
|
106
114
|
raise ArgumentError, "transport must respond to #call(method, url, headers, body, timeout)"
|
|
107
115
|
end
|
|
@@ -123,6 +131,8 @@ module Tina4
|
|
|
123
131
|
@transport = transport
|
|
124
132
|
@cookies_enabled = cookies ? true : false
|
|
125
133
|
@cookies = {}
|
|
134
|
+
# SSRF guard (ADR-0084): explicit allow-list of hosts / host:port / CIDRs.
|
|
135
|
+
@allow_hosts = allow_hosts || []
|
|
126
136
|
|
|
127
137
|
# Bearer wins over basic-auth when both passed
|
|
128
138
|
if bearer_token
|
|
@@ -258,6 +268,7 @@ module Tina4
|
|
|
258
268
|
headers = @headers.dup
|
|
259
269
|
cookie = cookie_header
|
|
260
270
|
headers["Cookie"] = cookie if @cookies_enabled && cookie
|
|
271
|
+
headers = origin_headers(uri, headers)
|
|
261
272
|
|
|
262
273
|
return download_via_transport(uri, headers, dest_path) if @transport
|
|
263
274
|
|
|
@@ -277,7 +288,7 @@ module Tina4
|
|
|
277
288
|
end
|
|
278
289
|
|
|
279
290
|
def set_basic_auth(username, password)
|
|
280
|
-
@headers["Authorization"] = "Basic #{Base64.strict_encode64("#{username}:#{password}")}"
|
|
291
|
+
@headers["Authorization"] = "Basic #{Tina4::Base64.strict_encode64("#{username}:#{password}")}"
|
|
281
292
|
self
|
|
282
293
|
end
|
|
283
294
|
|
|
@@ -447,10 +458,21 @@ module Tina4
|
|
|
447
458
|
when "HEAD" then Net::HTTP::Head
|
|
448
459
|
else raise ArgumentError, "unsupported stream method: #{method}"
|
|
449
460
|
end
|
|
461
|
+
# SSRF guard (ADR-0084): refuse a private/internal target before connect.
|
|
462
|
+
begin
|
|
463
|
+
Tina4::Ssrf.guard_url!(uri, @allow_hosts)
|
|
464
|
+
rescue Tina4::SsrfError => e
|
|
465
|
+
raise APIStreamError.new(e.message)
|
|
466
|
+
end
|
|
467
|
+
|
|
450
468
|
request = request_class.new(uri)
|
|
451
469
|
apply_headers(request, headers || {})
|
|
452
470
|
cookie = cookie_header
|
|
453
471
|
request["Cookie"] = cookie if @cookies_enabled && cookie
|
|
472
|
+
unless same_origin?(uri, URI.parse(@base_url))
|
|
473
|
+
request.delete("Authorization")
|
|
474
|
+
request.delete("Cookie")
|
|
475
|
+
end
|
|
454
476
|
if body
|
|
455
477
|
request.body = body.is_a?(String) ? body : JSON.generate(body)
|
|
456
478
|
request["Content-Type"] = content_type if content_type
|
|
@@ -458,12 +480,22 @@ module Tina4
|
|
|
458
480
|
request["Content-Type"] = content_type
|
|
459
481
|
end
|
|
460
482
|
|
|
483
|
+
# ADR-0060: connect_timeout (else TINA4_API_CONNECT_TIMEOUT, else 10s)
|
|
484
|
+
# bounds establishment and raises Net::OpenTimeout; timeout (else
|
|
485
|
+
# TINA4_API_TIMEOUT, else the client timeout) bounds the WHOLE stream and
|
|
486
|
+
# raises APIStreamTimeoutError. Both are Timeout::Error. `timeout` used to
|
|
487
|
+
# be only Net::HTTP's per-read idle timeout, so a server that kept
|
|
488
|
+
# dripping data streamed forever.
|
|
489
|
+
connect_bound = connect_timeout || stream_env_seconds("TINA4_API_CONNECT_TIMEOUT", 10.0)
|
|
490
|
+
total_bound = timeout || stream_env_seconds("TINA4_API_TIMEOUT", @timeout)
|
|
491
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + total_bound
|
|
492
|
+
|
|
461
493
|
http = Net::HTTP.new(uri.host, uri.port)
|
|
462
494
|
http.use_ssl = uri.scheme == "https"
|
|
463
495
|
http.verify_mode = OpenSSL::SSL::VERIFY_NONE if @verify_ssl == false
|
|
464
|
-
http.open_timeout =
|
|
465
|
-
http.read_timeout =
|
|
466
|
-
http.write_timeout =
|
|
496
|
+
http.open_timeout = [connect_bound, total_bound].min
|
|
497
|
+
http.read_timeout = total_bound
|
|
498
|
+
http.write_timeout = total_bound if http.respond_to?(:write_timeout=)
|
|
467
499
|
|
|
468
500
|
http.start do |conn|
|
|
469
501
|
conn.request(request) do |response|
|
|
@@ -473,9 +505,26 @@ module Tina4
|
|
|
473
505
|
raise APIStreamError.new("stream returned HTTP #{status}", status)
|
|
474
506
|
end
|
|
475
507
|
store_cookies(response.get_fields("Set-Cookie"))
|
|
476
|
-
response.read_body
|
|
508
|
+
response.read_body do |chunk|
|
|
509
|
+
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
510
|
+
raise APIStreamTimeoutError, "stream exceeded its total timeout of #{total_bound}s" unless remaining.positive?
|
|
511
|
+
|
|
512
|
+
# The next read may wait only for what is left of the deadline.
|
|
513
|
+
# tina4: bounded per read, so a server dripping single bytes inside
|
|
514
|
+
# one chunk header can overrun by one read at most.
|
|
515
|
+
conn.read_timeout = remaining
|
|
516
|
+
yield chunk
|
|
517
|
+
end
|
|
477
518
|
end
|
|
478
519
|
end
|
|
520
|
+
rescue Net::ReadTimeout, Net::WriteTimeout
|
|
521
|
+
raise APIStreamTimeoutError, "stream exceeded its total timeout of #{total_bound}s"
|
|
522
|
+
end
|
|
523
|
+
|
|
524
|
+
# A positive number of seconds from an env var; anything else falls back.
|
|
525
|
+
def stream_env_seconds(name, fallback)
|
|
526
|
+
seconds = Float(ENV[name].to_s.strip, exception: false)
|
|
527
|
+
seconds&.positive? ? seconds : fallback
|
|
479
528
|
end
|
|
480
529
|
|
|
481
530
|
def build_uri(path, params = {})
|
|
@@ -488,6 +537,10 @@ module Tina4
|
|
|
488
537
|
uri
|
|
489
538
|
end
|
|
490
539
|
|
|
540
|
+
def origin_headers(uri, headers)
|
|
541
|
+
same_origin?(uri, URI.parse(@base_url)) ? headers : strip_cross_origin(headers)
|
|
542
|
+
end
|
|
543
|
+
|
|
491
544
|
def apply_headers(request, extra_headers)
|
|
492
545
|
@headers.merge(extra_headers).each do |key, value|
|
|
493
546
|
request[key] = value
|
|
@@ -523,6 +576,7 @@ module Tina4
|
|
|
523
576
|
request.each_header { |key, value| headers[key] = value }
|
|
524
577
|
cookie = cookie_header
|
|
525
578
|
headers["cookie"] = cookie if @cookies_enabled && cookie
|
|
579
|
+
headers = origin_headers(uri, headers)
|
|
526
580
|
body = request.body
|
|
527
581
|
|
|
528
582
|
if @transport
|
|
@@ -552,6 +606,12 @@ module Tina4
|
|
|
552
606
|
current_body = body
|
|
553
607
|
|
|
554
608
|
loop do
|
|
609
|
+
# SSRF guard (ADR-0084): re-validated per hop, so a redirect to a
|
|
610
|
+
# private/internal address is refused even when the initial URL was
|
|
611
|
+
# public. Raises SsrfError; attempt_request/download turn it into a
|
|
612
|
+
# status-0 error response.
|
|
613
|
+
Tina4::Ssrf.guard_url!(current_uri, @allow_hosts)
|
|
614
|
+
|
|
555
615
|
http = Net::HTTP.new(current_uri.host, current_uri.port)
|
|
556
616
|
http.use_ssl = current_uri.scheme == "https"
|
|
557
617
|
# Only disable verification when EXPLICITLY false — nil/true keep the
|
|
@@ -803,6 +863,12 @@ module Tina4
|
|
|
803
863
|
end
|
|
804
864
|
end
|
|
805
865
|
|
|
866
|
+
# A stream_* call ran past its total timeout (timeout:, else
|
|
867
|
+
# TINA4_API_TIMEOUT). A Timeout::Error, as Python's ApiTimeoutError is a
|
|
868
|
+
# TimeoutError, and deliberately NOT an APIStreamError: callers such as
|
|
869
|
+
# AiClient treat APIStreamError as an HTTP status failure.
|
|
870
|
+
class APIStreamTimeoutError < Timeout::Error; end
|
|
871
|
+
|
|
806
872
|
class APIResponse
|
|
807
873
|
attr_reader :status, :body, :headers, :error, :path
|
|
808
874
|
|
|
@@ -821,7 +887,7 @@ module Tina4
|
|
|
821
887
|
end
|
|
822
888
|
|
|
823
889
|
def json
|
|
824
|
-
@json ||=
|
|
890
|
+
@json ||= Tina4.parse_json(@body)
|
|
825
891
|
rescue JSON::ParserError, TypeError
|
|
826
892
|
{}
|
|
827
893
|
end
|
data/lib/tina4/auth.rb
CHANGED
|
@@ -1,9 +1,17 @@
|
|
|
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 "openssl"
|
|
3
|
-
|
|
9
|
+
require_relative "base64"
|
|
4
10
|
require "json"
|
|
5
11
|
require "fileutils"
|
|
6
12
|
require "securerandom"
|
|
13
|
+
require_relative "parse_json"
|
|
14
|
+
require_relative "secret_file"
|
|
7
15
|
|
|
8
16
|
module Tina4
|
|
9
17
|
module Auth
|
|
@@ -60,13 +68,72 @@ module Tina4
|
|
|
60
68
|
# rejected for no real reason; RFC 7519 explicitly allows "a small leeway".
|
|
61
69
|
JWT_LEEWAY_SECONDS = 60
|
|
62
70
|
|
|
71
|
+
# Token purposes that are never an identity (ADR-0079 s1). Frond's
|
|
72
|
+
# form_token signs a JWT with the same TINA4_SECRET as an auth token, marked
|
|
73
|
+
# "type" => "form". A form token proves where a write came from, never who
|
|
74
|
+
# the caller is, so every identity gate refuses these purposes. valid_token
|
|
75
|
+
# itself stays purpose-neutral: CsrfMiddleware needs it for form tokens.
|
|
76
|
+
NON_IDENTITY_TOKEN_TYPES = %w[form].freeze
|
|
77
|
+
|
|
78
|
+
# Minimum HMAC key length in bytes (ADR-0079 s2): the HS256 output size,
|
|
79
|
+
# RFC 7518 s3.2. A blank key is the shortest case - anyone can reproduce an
|
|
80
|
+
# HMAC made with it.
|
|
81
|
+
MIN_SECRET_BYTES = 32
|
|
82
|
+
|
|
83
|
+
# The HMAC signing key is blank or shorter than MIN_SECRET_BYTES. An
|
|
84
|
+
# ArgumentError, Ruby's equivalent of Python's ValueError.
|
|
85
|
+
class InsecureSecretError < ArgumentError; end
|
|
86
|
+
|
|
63
87
|
class << self
|
|
88
|
+
# Explicitly provision an RS256 key pair in <root_dir>/.keys (opt-in). The
|
|
89
|
+
# framework never calls this on its own: a blank TINA4_SECRET used to mint
|
|
90
|
+
# a key pair implicitly at boot (ADR-0079 s2).
|
|
64
91
|
def setup(root_dir = Dir.pwd)
|
|
65
92
|
@keys_dir = File.join(root_dir, KEYS_DIR)
|
|
66
93
|
FileUtils.mkdir_p(@keys_dir)
|
|
67
94
|
ensure_keys
|
|
68
95
|
end
|
|
69
96
|
|
|
97
|
+
# Point RS256 at <root_dir>/.keys WITHOUT creating anything. Boot uses
|
|
98
|
+
# this, so an operator-provisioned key pair there keeps working.
|
|
99
|
+
def keys_root=(root_dir)
|
|
100
|
+
@keys_dir = File.join(root_dir, KEYS_DIR)
|
|
101
|
+
@private_key = nil
|
|
102
|
+
@public_key = nil
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# True when a VERIFIED payload may stand for a caller's identity. A payload
|
|
106
|
+
# whose "type" is a reserved non-identity purpose (a Frond form token) is
|
|
107
|
+
# refused; application payloads are otherwise untouched.
|
|
108
|
+
def identity_payload?(payload)
|
|
109
|
+
payload.is_a?(Hash) && !NON_IDENTITY_TOKEN_TYPES.include?(payload["type"])
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# The actionable error for a weak HMAC key, or nil when the key is usable.
|
|
113
|
+
def insecure_secret_message(secret)
|
|
114
|
+
length = secret.to_s.bytesize
|
|
115
|
+
return nil if length >= MIN_SECRET_BYTES
|
|
116
|
+
|
|
117
|
+
what = length.zero? ? "is not set" : "is #{length} bytes"
|
|
118
|
+
"Auth: TINA4_SECRET #{what}; an HMAC JWT secret must be at least " \
|
|
119
|
+
"#{MIN_SECRET_BYTES} bytes. Generate one with `openssl rand -hex 32` and set " \
|
|
120
|
+
"TINA4_SECRET in your environment or .env."
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Refuse to boot with a secret that makes tokens forgeable (ADR-0079 s2).
|
|
124
|
+
# Outside dev a blank TINA4_SECRET is refused; in any mode a set-but-short
|
|
125
|
+
# one is refused. An operator-provisioned RS256 key pair in .keys/ stands
|
|
126
|
+
# in for the secret. Raises InsecureSecretError with the actionable message.
|
|
127
|
+
def require_boot_secret!
|
|
128
|
+
return if rsa_keys_present?
|
|
129
|
+
|
|
130
|
+
secret = ENV["TINA4_SECRET"].to_s
|
|
131
|
+
return if secret.empty? && dev?
|
|
132
|
+
|
|
133
|
+
message = insecure_secret_message(secret)
|
|
134
|
+
raise InsecureSecretError, message if message
|
|
135
|
+
end
|
|
136
|
+
|
|
70
137
|
# Boot-time bootstrap (run once after env load, before auth is used).
|
|
71
138
|
#
|
|
72
139
|
# Mirrors the Python master's tina4_python.auth.ensure_dev_secret:
|
|
@@ -100,12 +167,10 @@ module Tina4
|
|
|
100
167
|
local_path = File.join(root_dir, ".env.local")
|
|
101
168
|
# If the file exists and does not end in a newline, prepend one so the
|
|
102
169
|
# new key lands on its own line rather than gluing onto the last value.
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
content
|
|
106
|
-
prefix = "\n" if !content.empty? && !content.end_with?("\n")
|
|
170
|
+
SecretFile.update(local_path) do |content|
|
|
171
|
+
prefix = !content.empty? && !content.end_with?("\n") ? "\n" : ""
|
|
172
|
+
content + "#{prefix}TINA4_SECRET=#{new_secret}\n"
|
|
107
173
|
end
|
|
108
|
-
File.open(local_path, "a") { |f| f.write("#{prefix}TINA4_SECRET=#{new_secret}\n") }
|
|
109
174
|
log_info("Auth: generated a development secret, saved to .env.local (gitignored)")
|
|
110
175
|
rescue StandardError => e
|
|
111
176
|
# Keep the in-memory secret for this run; just warn. Never crash boot.
|
|
@@ -117,15 +182,17 @@ module Tina4
|
|
|
117
182
|
|
|
118
183
|
# ── HS256 helpers (stdlib only, no gem) ──────────────────────
|
|
119
184
|
|
|
120
|
-
#
|
|
185
|
+
# HMAC with TINA4_SECRET unless an RS256 key pair has been provisioned in
|
|
186
|
+
# .keys/ (kept for existing deployments). A blank secret no longer falls
|
|
187
|
+
# back to an implicitly generated key pair: it is refused (ADR-0079 s2).
|
|
121
188
|
def use_hmac?
|
|
122
|
-
|
|
123
|
-
|
|
189
|
+
!rsa_keys_present?
|
|
190
|
+
end
|
|
124
191
|
|
|
125
|
-
|
|
192
|
+
def rsa_keys_present?
|
|
126
193
|
@keys_dir ||= File.join(Dir.pwd, KEYS_DIR)
|
|
127
|
-
|
|
128
|
-
File.exist?(File.join(@keys_dir, "public.pem"))
|
|
194
|
+
File.exist?(File.join(@keys_dir, "private.pem")) &&
|
|
195
|
+
File.exist?(File.join(@keys_dir, "public.pem"))
|
|
129
196
|
end
|
|
130
197
|
|
|
131
198
|
# Lazy per-call secret resolver. When the secret is blank, emit the
|
|
@@ -139,7 +206,7 @@ module Tina4
|
|
|
139
206
|
|
|
140
207
|
# Base64url-encode without padding (JWT spec)
|
|
141
208
|
def base64url_encode(data)
|
|
142
|
-
Base64.urlsafe_encode64(data, padding: false)
|
|
209
|
+
Tina4::Base64.urlsafe_encode64(data, padding: false)
|
|
143
210
|
end
|
|
144
211
|
|
|
145
212
|
# Base64url-decode (handles missing padding)
|
|
@@ -147,7 +214,7 @@ module Tina4
|
|
|
147
214
|
# Add back padding
|
|
148
215
|
remainder = str.length % 4
|
|
149
216
|
str += "=" * ((4 - remainder) % 4) if remainder != 0
|
|
150
|
-
Base64.urlsafe_decode64(str)
|
|
217
|
+
Tina4::Base64.urlsafe_decode64(str)
|
|
151
218
|
end
|
|
152
219
|
|
|
153
220
|
# Pick the JWT algorithm: explicit argument, else TINA4_JWT_ALGORITHM, else
|
|
@@ -174,6 +241,9 @@ module Tina4
|
|
|
174
241
|
# HMAC the signing input with the digest the algorithm actually names, so the
|
|
175
242
|
# header's "alg" can never disagree with the bytes we produced.
|
|
176
243
|
def hmac_signature(algorithm, secret, signing_input)
|
|
244
|
+
weak = insecure_secret_message(secret)
|
|
245
|
+
raise InsecureSecretError, weak if weak
|
|
246
|
+
|
|
177
247
|
OpenSSL::HMAC.digest(HMAC_ALGORITHMS.fetch(algorithm).new, secret.to_s, signing_input)
|
|
178
248
|
end
|
|
179
249
|
|
|
@@ -200,10 +270,10 @@ module Tina4
|
|
|
200
270
|
def decode_envelope(token, algorithm)
|
|
201
271
|
parts = token.to_s.split(".")
|
|
202
272
|
return nil unless parts.length == 3
|
|
203
|
-
return nil unless
|
|
273
|
+
return nil unless Tina4.parse_json(base64url_decode(parts[0]))["alg"] == algorithm
|
|
204
274
|
return nil unless yield("#{parts[0]}.#{parts[1]}", base64url_decode(parts[2]))
|
|
205
275
|
|
|
206
|
-
payload =
|
|
276
|
+
payload = Tina4.parse_json(base64url_decode(parts[1]))
|
|
207
277
|
now = Time.now.to_i
|
|
208
278
|
|
|
209
279
|
# RFC 7519 s4.1.4: "The processing of the 'exp' claim requires that the
|
|
@@ -247,6 +317,15 @@ module Tina4
|
|
|
247
317
|
# configuration error that must surface, not become a nil "invalid token".
|
|
248
318
|
alg = resolve_algorithm(algorithm)
|
|
249
319
|
|
|
320
|
+
# A blank or short key verifies tokens anyone can mint, so the token is
|
|
321
|
+
# REJECTED (fail closed with a 401, never a 500) and the operator is
|
|
322
|
+
# told why. ADR-0079 s2.
|
|
323
|
+
weak = insecure_secret_message(secret)
|
|
324
|
+
if weak
|
|
325
|
+
log_warning(weak)
|
|
326
|
+
return nil
|
|
327
|
+
end
|
|
328
|
+
|
|
250
329
|
decode_envelope(token, alg) do |input, signature|
|
|
251
330
|
expected = hmac_signature(alg, secret, input)
|
|
252
331
|
# Constant-time comparison to prevent timing attacks. Lengths must match
|
|
@@ -304,7 +383,6 @@ module Tina4
|
|
|
304
383
|
elsif use_hmac?
|
|
305
384
|
hmac_encode(claims, hmac_secret, algorithm: algorithm)
|
|
306
385
|
else
|
|
307
|
-
ensure_keys
|
|
308
386
|
rs256_encode(claims)
|
|
309
387
|
end
|
|
310
388
|
end
|
|
@@ -321,7 +399,6 @@ module Tina4
|
|
|
321
399
|
if use_hmac?
|
|
322
400
|
hmac_decode(token, hmac_secret) # returns Hash payload or nil
|
|
323
401
|
else
|
|
324
|
-
ensure_keys
|
|
325
402
|
rs256_decode(token)
|
|
326
403
|
end
|
|
327
404
|
end
|
|
@@ -361,7 +438,7 @@ module Tina4
|
|
|
361
438
|
return nil unless parts.length == 3
|
|
362
439
|
|
|
363
440
|
payload_json = base64url_decode(parts[1])
|
|
364
|
-
|
|
441
|
+
Tina4.parse_json(payload_json)
|
|
365
442
|
rescue ArgumentError, JSON::ParserError
|
|
366
443
|
nil
|
|
367
444
|
end
|
|
@@ -401,10 +478,11 @@ module Tina4
|
|
|
401
478
|
# than this process's env-resolved defaults.
|
|
402
479
|
payload = if secret || algorithm
|
|
403
480
|
hmac_decode(token, secret || hmac_secret, algorithm: algorithm)
|
|
404
|
-
|
|
405
|
-
|
|
481
|
+
else
|
|
482
|
+
valid_token(token)
|
|
406
483
|
end
|
|
407
|
-
|
|
484
|
+
# A form token is not an identity (ADR-0079 s1).
|
|
485
|
+
return identity_payload?(payload) ? payload : nil if payload
|
|
408
486
|
|
|
409
487
|
# API_KEY bypass — timing-safe comparison via validate_api_key
|
|
410
488
|
# (OpenSSL.fixed_length_secure_compare). Parity with Python's
|
|
@@ -445,9 +523,13 @@ module Tina4
|
|
|
445
523
|
token = Regexp.last_match(1)
|
|
446
524
|
|
|
447
525
|
# JWT first, then the API key — same order and same payload shape as
|
|
448
|
-
# authenticate_request above (and as Python/PHP/Node).
|
|
449
|
-
|
|
450
|
-
|
|
526
|
+
# authenticate_request above (and as Python/PHP/Node). A form token is
|
|
527
|
+
# not an identity (ADR-0079 s1).
|
|
528
|
+
payload = valid_token(token)
|
|
529
|
+
if payload
|
|
530
|
+
return false unless identity_payload?(payload)
|
|
531
|
+
|
|
532
|
+
env["tina4.auth"] = payload
|
|
451
533
|
return true
|
|
452
534
|
end
|
|
453
535
|
|
|
@@ -564,7 +646,7 @@ module Tina4
|
|
|
564
646
|
def generate_keys
|
|
565
647
|
Tina4::Log.info("Generating RSA key pair for JWT authentication")
|
|
566
648
|
key = OpenSSL::PKey::RSA.generate(2048)
|
|
567
|
-
|
|
649
|
+
SecretFile.update(private_key_path) { key.to_pem }
|
|
568
650
|
File.write(public_key_path, key.public_key.to_pem)
|
|
569
651
|
@private_key = nil
|
|
570
652
|
@public_key = nil
|
data/lib/tina4/auto_crud.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 "json"
|
|
3
9
|
|
|
4
10
|
module Tina4
|
|
@@ -105,17 +111,46 @@ module Tina4
|
|
|
105
111
|
page = [page, 1].max
|
|
106
112
|
limit = per_page
|
|
107
113
|
offset = req.query["offset"] ? req.query["offset"].to_i : (page - 1) * per_page
|
|
108
|
-
order_by = parse_sort(req.query["sort"])
|
|
109
114
|
|
|
110
|
-
# Filter support: ?filter[field]=value
|
|
115
|
+
# Filter support: ?filter[field]=value. ADR-0069: ANY bracket key is
|
|
116
|
+
# captured and must resolve to a declared field (attribute or its
|
|
117
|
+
# mapped column); an unknown key is a 400 before any SQL runs.
|
|
118
|
+
#
|
|
119
|
+
# The query parser keeps bracketed keys flat, so a list or map shows
|
|
120
|
+
# up as "sort[...]" or "filter[name][...]": a 400, never ignored.
|
|
121
|
+
if req.query.keys.any? { |key| key.start_with?("sort[") }
|
|
122
|
+
next res.error("INVALID_QUERY_PARAMETER",
|
|
123
|
+
"Query parameter 'sort' must be a single comma-separated string", 400)
|
|
124
|
+
end
|
|
125
|
+
|
|
111
126
|
filter_conditions = []
|
|
112
127
|
filter_values = []
|
|
128
|
+
unknown_filter_field = nil
|
|
129
|
+
nested_filter_field = nil
|
|
113
130
|
req.query.each do |key, value|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
131
|
+
next unless key =~ /\Afilter\[(.*)\]\z/m
|
|
132
|
+
|
|
133
|
+
filter_key = Regexp.last_match(1)
|
|
134
|
+
if filter_key.include?("][")
|
|
135
|
+
nested_filter_field = filter_key.split("][", 2).first
|
|
136
|
+
break
|
|
117
137
|
end
|
|
138
|
+
column = model_class.resolve_field_column(filter_key)
|
|
139
|
+
unless column
|
|
140
|
+
unknown_filter_field = filter_key
|
|
141
|
+
break
|
|
142
|
+
end
|
|
143
|
+
filter_conditions << "#{column} = ?"
|
|
144
|
+
filter_values << value
|
|
145
|
+
end
|
|
146
|
+
if nested_filter_field
|
|
147
|
+
next res.error("INVALID_QUERY_PARAMETER",
|
|
148
|
+
"Filter value for '#{nested_filter_field}' must be a single value", 400)
|
|
118
149
|
end
|
|
150
|
+
next res.error("UNKNOWN_FIELD", "Unknown filter field '#{unknown_filter_field}'", 400) if unknown_filter_field
|
|
151
|
+
|
|
152
|
+
order_by, unknown_sort_field = parse_sort(model_class, req.query["sort"])
|
|
153
|
+
next res.error("UNKNOWN_FIELD", "Unknown sort field '#{unknown_sort_field}'", 400) if unknown_sort_field
|
|
119
154
|
|
|
120
155
|
if filter_conditions.empty?
|
|
121
156
|
records = model_class.all(limit: limit, offset: offset, order_by: order_by)
|
|
@@ -150,8 +185,7 @@ module Tina4
|
|
|
150
185
|
# GET /api/{table}/{id} -- get single record
|
|
151
186
|
Tina4::Router.add("GET", "#{prefix}/#{table}/{id}", proc { |req, res|
|
|
152
187
|
begin
|
|
153
|
-
|
|
154
|
-
record = model_class.find_by_id(id.to_i)
|
|
188
|
+
record = find_addressed_record(model_class, req.params["id"])
|
|
155
189
|
if record
|
|
156
190
|
res.json({ data: record.to_h })
|
|
157
191
|
else
|
|
@@ -200,8 +234,7 @@ module Tina4
|
|
|
200
234
|
# PUT /api/{table}/{id} -- update record
|
|
201
235
|
put_route = Tina4::Router.add("PUT", "#{prefix}/#{table}/{id}", proc { |req, res|
|
|
202
236
|
begin
|
|
203
|
-
|
|
204
|
-
record = model_class.find_by_id(id.to_i)
|
|
237
|
+
record = find_addressed_record(model_class, req.params["id"])
|
|
205
238
|
unless record
|
|
206
239
|
next res.json({ error: "Not found" }, status: 404)
|
|
207
240
|
end
|
|
@@ -236,8 +269,7 @@ module Tina4
|
|
|
236
269
|
# DELETE /api/{table}/{id} -- delete record
|
|
237
270
|
delete_route = Tina4::Router.add("DELETE", "#{prefix}/#{table}/{id}", proc { |req, res|
|
|
238
271
|
begin
|
|
239
|
-
|
|
240
|
-
record = model_class.find_by_id(id.to_i)
|
|
272
|
+
record = find_addressed_record(model_class, req.params["id"])
|
|
241
273
|
unless record
|
|
242
274
|
next res.json({ error: "Not found" }, status: 404)
|
|
243
275
|
end
|
|
@@ -314,27 +346,51 @@ module Tina4
|
|
|
314
346
|
auto_increment = single_pk && defs[single_pk.to_sym] && defs[single_pk.to_sym][:auto_increment]
|
|
315
347
|
strip_pk = is_create ? !(single_pk && !auto_increment) : true
|
|
316
348
|
|
|
349
|
+
# ADR-0069: a body key is written only when it resolves to a DECLARED
|
|
350
|
+
# field, by attribute name or its mapped column - the SAME resolver the
|
|
351
|
+
# list route's filter/sort use. Anything else is dropped (writes drop,
|
|
352
|
+
# reads 400).
|
|
317
353
|
data.each_with_object({}) do |(key, value), allowed|
|
|
318
|
-
|
|
319
|
-
next unless
|
|
320
|
-
next if key_s == "is_deleted"
|
|
321
|
-
next if strip_pk && pk_fields.include?(key_s)
|
|
354
|
+
attribute = model_class.resolve_field(key)
|
|
355
|
+
next unless attribute
|
|
322
356
|
|
|
323
|
-
|
|
357
|
+
attribute_name = attribute.to_s
|
|
358
|
+
next if attribute_name == "is_deleted"
|
|
359
|
+
next if strip_pk && pk_fields.include?(attribute_name)
|
|
360
|
+
|
|
361
|
+
allowed[attribute_name] = value
|
|
324
362
|
end
|
|
325
363
|
end
|
|
326
364
|
|
|
327
|
-
#
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
365
|
+
# The row a URL {id} addresses, or nil. The id is bound, never
|
|
366
|
+
# interpolated; ORM.coerce_primary_key keeps a string natural key as-is
|
|
367
|
+
# and answers nil for a non-integer id on an integer key (-> 404).
|
|
368
|
+
def find_addressed_record(model_class, raw_id)
|
|
369
|
+
id = model_class.coerce_primary_key(raw_id)
|
|
370
|
+
id.nil? ? nil : model_class.find_by_id(id)
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
# Parse the sort parameter: "-name,created_at" => "name DESC, created_at ASC".
|
|
374
|
+
#
|
|
375
|
+
# ADR-0069: each part (empty parts skipped, a leading "-" means DESC) must
|
|
376
|
+
# resolve to a declared field; the ORDER BY is built ONLY from resolved
|
|
377
|
+
# columns plus ASC/DESC. Returns [order_by_or_nil, unknown_field_or_nil].
|
|
378
|
+
def parse_sort(model_class, sort_str)
|
|
379
|
+
return [nil, nil] if sort_str.nil? || sort_str.empty?
|
|
380
|
+
|
|
381
|
+
parts = []
|
|
382
|
+
sort_str.split(",").each do |part|
|
|
383
|
+
part = part.strip
|
|
384
|
+
next if part.empty?
|
|
385
|
+
|
|
386
|
+
descending = part.start_with?("-")
|
|
387
|
+
field = descending ? part[1..].strip : part
|
|
388
|
+
column = model_class.resolve_field_column(field)
|
|
389
|
+
return [nil, field] unless column
|
|
390
|
+
|
|
391
|
+
parts << "#{column} #{descending ? 'DESC' : 'ASC'}"
|
|
392
|
+
end
|
|
393
|
+
[parts.empty? ? nil : parts.join(", "), nil]
|
|
338
394
|
end
|
|
339
395
|
end
|
|
340
396
|
end
|
data/lib/tina4/background.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
|
# Periodic background task registry.
|
|
@@ -9,7 +15,7 @@ module Tina4
|
|
|
9
15
|
#
|
|
10
16
|
# Ruby has no asyncio event loop, so each task runs in its own dedicated OS
|
|
11
17
|
# thread, started at registration time. Because the thread runs regardless of
|
|
12
|
-
# which web server (Puma
|
|
18
|
+
# which web server (the built-in server or Puma) is in front, a Ruby background task is never
|
|
13
19
|
# a silent no-op under production — the thread IS the runtime (contrast the
|
|
14
20
|
# Python ASGI / PHP-FPM silent-no-op the other frameworks had to fix). The GIL
|
|
15
21
|
# keeps it cooperative-enough for the periodic work this is meant for (queue
|
data/lib/tina4/base64.rb
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
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
|
+
module Tina4
|
|
10
|
+
# Base64 on core Array#pack / String#unpack1, so Tina4 needs no base64 gem.
|
|
11
|
+
#
|
|
12
|
+
# The base64 gem stopped being a default gem in Ruby 3.4, and a bundled gem an
|
|
13
|
+
# app does not declare is not on the load path under Bundler. The gem is itself
|
|
14
|
+
# a thin wrapper over pack("m"), so this module keeps its method names and its
|
|
15
|
+
# exact semantics (same bytes out, same ArgumentError on bad strict input) and
|
|
16
|
+
# the call sites read the same with a Tina4:: prefix.
|
|
17
|
+
module Base64
|
|
18
|
+
module_function
|
|
19
|
+
|
|
20
|
+
# RFC 2045 (MIME): a newline after every 60 encoded characters and at the end.
|
|
21
|
+
def encode64(binary)
|
|
22
|
+
[binary].pack("m")
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Lenient decode: characters outside the alphabet are skipped, never raised on.
|
|
26
|
+
def decode64(string)
|
|
27
|
+
string.unpack1("m")
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# RFC 4648: no line feeds.
|
|
31
|
+
def strict_encode64(binary)
|
|
32
|
+
[binary].pack("m0")
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Raises ArgumentError on anything that is not canonical, padded base64.
|
|
36
|
+
def strict_decode64(string)
|
|
37
|
+
string.unpack1("m0")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# RFC 4648 URL-safe alphabet ("-" and "_"), padding optional.
|
|
41
|
+
def urlsafe_encode64(binary, padding: true)
|
|
42
|
+
encoded = strict_encode64(binary)
|
|
43
|
+
encoded.chomp!("==") or encoded.chomp!("=") unless padding
|
|
44
|
+
encoded.tr!("+/", "-_")
|
|
45
|
+
encoded
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Accepts padded or unpadded input; raises ArgumentError on invalid input.
|
|
49
|
+
def urlsafe_decode64(string)
|
|
50
|
+
if !string.end_with?("=") && string.length % 4 != 0
|
|
51
|
+
string = string.ljust((string.length + 3) & ~3, "=")
|
|
52
|
+
string.tr!("-_", "+/")
|
|
53
|
+
else
|
|
54
|
+
string = string.tr("-_", "+/")
|
|
55
|
+
end
|
|
56
|
+
strict_decode64(string)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|