signingflow 0.1.0
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 +7 -0
- data/CHANGELOG.md +9 -0
- data/LICENSE +21 -0
- data/README.md +96 -0
- data/lib/signingflow/allowlist.rb +13 -0
- data/lib/signingflow/client.rb +17 -0
- data/lib/signingflow/config.rb +82 -0
- data/lib/signingflow/errors.rb +39 -0
- data/lib/signingflow/jail.rb +46 -0
- data/lib/signingflow/object.rb +95 -0
- data/lib/signingflow/path.rb +110 -0
- data/lib/signingflow/redact.rb +31 -0
- data/lib/signingflow/requestor.rb +246 -0
- data/lib/signingflow/resources.rb +624 -0
- data/lib/signingflow/status.rb +82 -0
- data/lib/signingflow/version.rb +6 -0
- data/lib/signingflow/webhook.rb +46 -0
- data/lib/signingflow.rb +25 -0
- data/resources/allowlist.json +110 -0
- metadata +121 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 69511c1fa920936b6084ce36ce00041549b9c5355429684271af523e2842274c
|
|
4
|
+
data.tar.gz: 28b0122a3cbe4c369a23d89f8359e4ec23295497e58f70d978f510ea70480463
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: b899b5d35246dc3bd64830749f79528b81c3c132834f3401c3b85320d33b253233f81de30ac160f70eb641dbe3ea7aeaf31d916dc945ca8fbc3dd5a75acb0c54
|
|
7
|
+
data.tar.gz: 5d263d39d08b7f7933bb41a4d8cf4ea8ce21b9a4eab08bc00c1f5afd0384a86a3c3aa7b9c807c642ea0fea25d7443b49b9b2ac34d521951c4087e65c266fcd32
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0
|
|
4
|
+
|
|
5
|
+
First Ruby SDK for sending a document, reading envelope status, downloading a completed file, and verifying webhook signatures.
|
|
6
|
+
|
|
7
|
+
Install from GitHub with `gem "signingflow", github: "Signingflow/signingflow-ruby"` until the gem is on RubyGems.
|
|
8
|
+
|
|
9
|
+
A query value that contains the API key is rejected, including when that key is percent-encoded. The API host is HTTPS `api.signingflow.com` unless `allow_custom_base_url` is set. A file path is read only from `upload_root`. A hard link is rejected, and the file is not opened when the runtime cannot refuse a symlink. `construct_event` returns `payload` and `body_sha256` of the raw body. A 409 is an idempotency error only when the body says the key was already used. A timed-out send is returned only when `sent_time` falls in the two minutes after the call started. The client sends `Accept-Encoding: identity` and stops reading an API response at 8 MB.
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Signingflow
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# signingflow
|
|
2
|
+
|
|
3
|
+
[](https://github.com/Signingflow/signingflow-ruby/actions/workflows/ci.yml)
|
|
4
|
+
[](https://github.com/Signingflow/signingflow-ruby/actions/workflows/ci.yml)
|
|
5
|
+
[](LICENSE)
|
|
6
|
+
|
|
7
|
+
Ruby SDK for the Signingflow calls used to send a document, read envelope status, and receive webhooks.
|
|
8
|
+
|
|
9
|
+
The gem talks to `https://api.signingflow.com`. Keep the API key on your server. Ruby 3.2 or newer is required.
|
|
10
|
+
|
|
11
|
+
```ruby
|
|
12
|
+
gem "signingflow", github: "Signingflow/signingflow-ruby"
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
The gem name is `signingflow`. Install it from [GitHub](https://github.com/Signingflow/signingflow-ruby) until it is published on RubyGems.
|
|
16
|
+
|
|
17
|
+
```ruby
|
|
18
|
+
require "signingflow"
|
|
19
|
+
|
|
20
|
+
client = Signingflow::Client.new(
|
|
21
|
+
ENV.fetch("SIGNINGFLOW_API_KEY"),
|
|
22
|
+
workspace_id: ENV.fetch("SIGNINGFLOW_WORKSPACE_ID"),
|
|
23
|
+
env: "sandbox",
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
envelope = client.envelopes.send_document(
|
|
27
|
+
title: "NDA",
|
|
28
|
+
file: pdf_bytes,
|
|
29
|
+
filename: "contract.pdf",
|
|
30
|
+
recipients: [{ name: "Ada Lovelace", email: "ada@example.com" }],
|
|
31
|
+
message: "Please sign",
|
|
32
|
+
)
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
`file` is the raw bytes. A disk path is `path:`, and it is read only from `upload_root`. That directory must be one this process writes. The open refuses a symlink, a hard link, and a path that leaves the directory. If this Ruby cannot open a path without following a symlink, the read is refused.
|
|
36
|
+
|
|
37
|
+
The client sends `Accept-Encoding: identity` and stops reading once an API response passes 8 MB.
|
|
38
|
+
|
|
39
|
+
`env` is `sandbox` or `live`. The client always sends `X-Request-Env`. A missing environment header is treated as live by the API.
|
|
40
|
+
|
|
41
|
+
`base_url` must be `https`. The host is `api.signingflow.com` unless `allow_custom_base_url` is set. A username or password on the URL is rejected.
|
|
42
|
+
|
|
43
|
+
## Send and read
|
|
44
|
+
|
|
45
|
+
`envelopes.prepare_draft` stores one PDF and creates a draft. It does not email anyone.
|
|
46
|
+
|
|
47
|
+
`envelopes.send_document` stores the PDF, creates the draft, adds the recipients to the first document, and sends. `envelopes.send_from_template` starts from `templates/{id}`.
|
|
48
|
+
|
|
49
|
+
If update or send fails, the error includes `envelope_name` for the draft that was saved. After a timeout, the client returns the envelope only when `sent_time` is within two minutes after the call started and not more than a minute ahead of this clock. An older or far-future timestamp is an error.
|
|
50
|
+
|
|
51
|
+
`envelopes.list(directory: "needAction")` reads pages of 10. `page_size` can be from 1 to 1000.
|
|
52
|
+
|
|
53
|
+
`envelopes.download_completed(name)` reads the envelope first. It requests the download URL only when the state is `COMPLETED`. The download call returns `uri`, `download_time`, and `etag`. This client does not GET `uri`. Treat that URL as untrusted until your application checks the host. Do not send the API key to it.
|
|
54
|
+
|
|
55
|
+
`envelopes.remind(name)` notifies people who can still sign. The call is not idempotent.
|
|
56
|
+
|
|
57
|
+
## Idempotency
|
|
58
|
+
|
|
59
|
+
Creates and sends send a UUID `idempotencyKey` in the JSON body. The same key is stored for the whole environment. A repeat returns HTTP 409 and does not return the original resource. The SDK raises `Signingflow::IdempotencyError` with `resource_name` when the error includes one. It does not treat that response as success.
|
|
60
|
+
|
|
61
|
+
Pass the same `idempotency_key` only to retry one request after a timeout. `update` and `remind` are not retried automatically.
|
|
62
|
+
|
|
63
|
+
## Webhooks
|
|
64
|
+
|
|
65
|
+
```ruby
|
|
66
|
+
destination = client.webhook_urls.create(
|
|
67
|
+
display_name: "Envelope events",
|
|
68
|
+
http_request_uri: "https://example.com/webhooks/signingflow",
|
|
69
|
+
events: ["DOC_ENVELOPE_COMPLETED"],
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
secret = Signingflow.signing_secret_of(destination)
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Store that secret from the create response. A later `retrieve` does not include it. Do not set `http_config`. `auth: "NONE"` is rejected.
|
|
76
|
+
|
|
77
|
+
```ruby
|
|
78
|
+
event = Signingflow.construct_event(raw_body, signature_header, secret, previous_secret: previous, previous_signature: previous_signature)
|
|
79
|
+
event.payload
|
|
80
|
+
event.body_sha256
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
The signature is lowercase hex HMAC-SHA256 of the raw body. There is no timestamp. Compare `X-Signingflow-Signature` or `X-Webhook-Signature`. During a grace window, also pass the previous header and secret. Parse JSON only after the MAC matches. `body_sha256` is the SHA-256 of those raw bytes. Store it and reject a delivery you have already seen.
|
|
84
|
+
|
|
85
|
+
## Errors
|
|
86
|
+
|
|
87
|
+
| Class | When |
|
|
88
|
+
| --- | --- |
|
|
89
|
+
| `Signingflow::AuthenticationError` | HTTP 401 |
|
|
90
|
+
| `Signingflow::PermissionError` | HTTP 403 |
|
|
91
|
+
| `Signingflow::IdempotencyError` | HTTP 409 whose body mentions idempotency |
|
|
92
|
+
| `Signingflow::RateLimitError` | HTTP 429 |
|
|
93
|
+
| `Signingflow::InvalidRequestError` | Other HTTP 4xx |
|
|
94
|
+
| `Signingflow::APIError` | HTTP 5xx, or a response larger than 8 MB |
|
|
95
|
+
| `Signingflow::ConnectionError` | The API could not be reached or timed out |
|
|
96
|
+
| `Signingflow::SignatureVerificationError` | The webhook MAC does not match |
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Signingflow
|
|
6
|
+
ALLOWLIST_PATH = File.expand_path("../../resources/allowlist.json", __dir__)
|
|
7
|
+
OPERATIONS = JSON.parse(File.read(ALLOWLIST_PATH)).fetch("operations").freeze
|
|
8
|
+
OPERATIONS_BY_ID = OPERATIONS.to_h { |operation| [operation.fetch("operationId"), operation] }.freeze
|
|
9
|
+
|
|
10
|
+
def self.operation(operation_id)
|
|
11
|
+
OPERATIONS_BY_ID.fetch(operation_id)
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Signingflow
|
|
4
|
+
class Client
|
|
5
|
+
attr_reader :files, :envelopes, :templates, :webhook_urls, :webhook_deliveries
|
|
6
|
+
|
|
7
|
+
def initialize(api_key, workspace_id:, env:, **options)
|
|
8
|
+
config = ConfigResolver.resolve(api_key, workspace_id: workspace_id, env: env, **options)
|
|
9
|
+
requestor = Requestor.new(config)
|
|
10
|
+
@files = Resources::Files.new(requestor, config)
|
|
11
|
+
@templates = Resources::Templates.new(requestor)
|
|
12
|
+
@envelopes = Resources::Envelopes.new(requestor, @files, @templates)
|
|
13
|
+
@webhook_urls = Resources::WebhookUrls.new(requestor)
|
|
14
|
+
@webhook_deliveries = Resources::WebhookDeliveries.new(requestor)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Signingflow
|
|
4
|
+
Config = Data.define(
|
|
5
|
+
:api_key, :workspace_id, :env, :base_url, :timeout, :max_network_retries,
|
|
6
|
+
:channel, :funnel, :upload_hosts, :upload_root, :source
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
module ConfigResolver
|
|
10
|
+
DEFAULT_UPLOAD_HOSTS = ["storage.googleapis.com"].freeze
|
|
11
|
+
OFFICIAL_HOST = "api.signingflow.com"
|
|
12
|
+
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
def resolve(api_key, workspace_id:, env:, base_url: nil, allow_custom_base_url: false, timeout: 30_000,
|
|
16
|
+
max_network_retries: 2, channel: nil, funnel: nil, upload_hosts: nil, upload_root: nil)
|
|
17
|
+
key = api_key.to_s.strip
|
|
18
|
+
raise Error, "API key is required." if key.empty?
|
|
19
|
+
workspace = workspace_id.to_s.strip
|
|
20
|
+
raise Error, "workspace_id is required." if workspace.empty?
|
|
21
|
+
|
|
22
|
+
raw_base = base_url.to_s.strip
|
|
23
|
+
raw_base = "https://api.signingflow.com" if raw_base.empty?
|
|
24
|
+
raw_base = raw_base.sub(%r{/+\z}, "")
|
|
25
|
+
begin
|
|
26
|
+
parsed = URI.parse(raw_base)
|
|
27
|
+
rescue URI::InvalidURIError
|
|
28
|
+
raise Error, "base_url must be an absolute URL."
|
|
29
|
+
end
|
|
30
|
+
raise Error, "base_url must be an absolute URL." if parsed.host.nil? || parsed.scheme.nil?
|
|
31
|
+
raise Error, "base_url must be https." unless parsed.scheme == "https"
|
|
32
|
+
raise Error, "base_url must not include a username or password." if parsed.user || parsed.password
|
|
33
|
+
if parsed.host != OFFICIAL_HOST && !allow_custom_base_url
|
|
34
|
+
raise Error, "base_url host must be api.signingflow.com, or set allow_custom_base_url."
|
|
35
|
+
end
|
|
36
|
+
unless timeout.is_a?(Numeric) && timeout.positive?
|
|
37
|
+
raise Error, "timeout must be a positive number of milliseconds."
|
|
38
|
+
end
|
|
39
|
+
unless max_network_retries.is_a?(Integer) && max_network_retries >= 0
|
|
40
|
+
raise Error, "max_network_retries must be a whole number of zero or more."
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
Config.new(
|
|
44
|
+
api_key: key,
|
|
45
|
+
workspace_id: workspace,
|
|
46
|
+
env: normalize_env(env),
|
|
47
|
+
base_url: raw_base,
|
|
48
|
+
timeout: timeout,
|
|
49
|
+
max_network_retries: max_network_retries,
|
|
50
|
+
channel: blank(channel),
|
|
51
|
+
funnel: blank(funnel),
|
|
52
|
+
upload_hosts: upload_hosts_of(upload_hosts),
|
|
53
|
+
upload_root: blank(upload_root),
|
|
54
|
+
source: SOURCE
|
|
55
|
+
)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def normalize_env(env)
|
|
59
|
+
value = env.to_s
|
|
60
|
+
return "SANDBOX" if value == "sandbox" || value == "SANDBOX"
|
|
61
|
+
return "LIVE" if value == "live" || value == "LIVE"
|
|
62
|
+
|
|
63
|
+
raise Error, "env must be sandbox or live."
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def upload_hosts_of(hosts)
|
|
67
|
+
list = (hosts || DEFAULT_UPLOAD_HOSTS).map { |host| host.to_s.strip.downcase }.reject(&:empty?)
|
|
68
|
+
list.each do |host|
|
|
69
|
+
dots = host.count(".")
|
|
70
|
+
if dots < 2 || host.start_with?(".") || host.end_with?(".") || host.include?("..")
|
|
71
|
+
raise Error, "Each upload_hosts entry must be a specific host, not a public suffix."
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
list.freeze
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def blank(value)
|
|
78
|
+
trimmed = value.to_s.strip
|
|
79
|
+
trimmed.empty? ? nil : trimmed
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Signingflow
|
|
4
|
+
class Error < StandardError
|
|
5
|
+
attr_reader :status_code, :grpc_code, :trace_code, :request_id, :idempotency_key,
|
|
6
|
+
:capability, :resource_name, :retry_after, :field_violations
|
|
7
|
+
attr_accessor :envelope_name
|
|
8
|
+
|
|
9
|
+
def initialize(message = nil, status_code: nil, grpc_code: nil, trace_code: nil, request_id: nil,
|
|
10
|
+
idempotency_key: nil, capability: nil, resource_name: nil, envelope_name: nil,
|
|
11
|
+
retry_after: nil, field_violations: nil)
|
|
12
|
+
super(message)
|
|
13
|
+
@status_code = status_code
|
|
14
|
+
@grpc_code = grpc_code
|
|
15
|
+
@trace_code = trace_code
|
|
16
|
+
@request_id = request_id
|
|
17
|
+
@idempotency_key = idempotency_key
|
|
18
|
+
@capability = capability
|
|
19
|
+
@resource_name = resource_name
|
|
20
|
+
@envelope_name = envelope_name
|
|
21
|
+
@retry_after = retry_after
|
|
22
|
+
@field_violations = field_violations
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.attach_envelope(error, envelope_name)
|
|
26
|
+
error.envelope_name = envelope_name if error.is_a?(Error)
|
|
27
|
+
raise error
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
class AuthenticationError < Error; end
|
|
32
|
+
class PermissionError < Error; end
|
|
33
|
+
class IdempotencyError < Error; end
|
|
34
|
+
class RateLimitError < Error; end
|
|
35
|
+
class InvalidRequestError < Error; end
|
|
36
|
+
class APIError < Error; end
|
|
37
|
+
class ConnectionError < Error; end
|
|
38
|
+
class SignatureVerificationError < Error; end
|
|
39
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
|
|
5
|
+
module Signingflow
|
|
6
|
+
module Jail
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def read(root, requested, max_bytes)
|
|
10
|
+
base = File.realpath(root)
|
|
11
|
+
resolved = File.expand_path(requested.to_s, base)
|
|
12
|
+
parent_real = File.realpath(File.dirname(resolved))
|
|
13
|
+
raise Error, "outside root" if escapes?(base, parent_real)
|
|
14
|
+
|
|
15
|
+
leaf = File.basename(resolved)
|
|
16
|
+
raise Error, "outside root" if leaf.empty? || leaf == "." || leaf == ".."
|
|
17
|
+
|
|
18
|
+
path = File.join(parent_real, leaf)
|
|
19
|
+
raise Error, "outside root" if escapes?(base, path) || File.expand_path(path) == File.expand_path(base)
|
|
20
|
+
raise Error, "nofollow" unless Jail.nofollow_available?
|
|
21
|
+
|
|
22
|
+
io = IO.new(IO.sysopen(path, File::RDONLY | File::NOFOLLOW))
|
|
23
|
+
begin
|
|
24
|
+
info = io.stat
|
|
25
|
+
raise Error, "not a file" unless info.file?
|
|
26
|
+
raise Error, "hard link" if info.nlink > 1
|
|
27
|
+
raise Error, "too large" if info.size > max_bytes
|
|
28
|
+
|
|
29
|
+
io.read(info.size) || +""
|
|
30
|
+
ensure
|
|
31
|
+
io.close
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.nofollow_available?
|
|
36
|
+
defined?(File::NOFOLLOW) == "constant"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def escapes?(base, target)
|
|
40
|
+
relative = Pathname(target).relative_path_from(Pathname(base)).to_s
|
|
41
|
+
return true if relative.empty?
|
|
42
|
+
|
|
43
|
+
relative.split(/[\\\/]/).include?("..")
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Signingflow
|
|
4
|
+
class ResourceObject
|
|
5
|
+
def initialize(data)
|
|
6
|
+
@data = data.is_a?(Hash) ? data : {}
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def [](key)
|
|
10
|
+
lookup(key.to_s)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def []=(key, value)
|
|
14
|
+
@data[key.to_s] = value
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def to_h
|
|
18
|
+
@data
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def key?(name)
|
|
22
|
+
!lookup(name.to_s).nil? || @data.key?(name.to_s) || @data.key?(camel(name.to_s))
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def method_missing(name, *args)
|
|
26
|
+
return super if !args.empty? || name.to_s.end_with?("=")
|
|
27
|
+
|
|
28
|
+
lookup(name.to_s)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def respond_to_missing?(name, include_private = false)
|
|
32
|
+
key?(name) || super
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def lookup(key)
|
|
38
|
+
return @data[key] if @data.key?(key)
|
|
39
|
+
|
|
40
|
+
camel_key = camel(key)
|
|
41
|
+
return @data[camel_key] if @data.key?(camel_key)
|
|
42
|
+
|
|
43
|
+
nil
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def camel(key)
|
|
47
|
+
key.gsub(/_([a-z])/) { Regexp.last_match(1).upcase }
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
class List
|
|
52
|
+
MAX_PAGES = 100
|
|
53
|
+
|
|
54
|
+
attr_reader :data, :next_page_token
|
|
55
|
+
|
|
56
|
+
def initialize(data:, next_page_token:, loader:)
|
|
57
|
+
@data = data
|
|
58
|
+
@next_page_token = next_page_token
|
|
59
|
+
@loader = loader
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def auto_paging_each
|
|
63
|
+
page_data = @data
|
|
64
|
+
token = @next_page_token
|
|
65
|
+
seen = {}
|
|
66
|
+
pages = 0
|
|
67
|
+
loop do
|
|
68
|
+
page_data.each do |item|
|
|
69
|
+
keep = yield item
|
|
70
|
+
return if keep == false
|
|
71
|
+
end
|
|
72
|
+
return if token.nil? || token.empty? || seen[token]
|
|
73
|
+
|
|
74
|
+
pages += 1
|
|
75
|
+
raise InvalidRequestError, "Too many pages." if pages > MAX_PAGES
|
|
76
|
+
|
|
77
|
+
seen[token] = true
|
|
78
|
+
page = @loader.call(token)
|
|
79
|
+
page_data = page.data
|
|
80
|
+
token = page.next_page_token
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def auto_paging_to_array(limit: nil)
|
|
85
|
+
items = []
|
|
86
|
+
auto_paging_each do |item|
|
|
87
|
+
break false if !limit.nil? && items.length >= limit
|
|
88
|
+
|
|
89
|
+
items << item
|
|
90
|
+
limit.nil? || items.length < limit
|
|
91
|
+
end
|
|
92
|
+
items
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "cgi"
|
|
4
|
+
require "uri"
|
|
5
|
+
|
|
6
|
+
module Signingflow
|
|
7
|
+
module Path
|
|
8
|
+
SEGMENT = /\A[A-Za-z0-9_-]+\z/
|
|
9
|
+
MAX_DELAY_MS = 60_000
|
|
10
|
+
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def fill(template, params)
|
|
14
|
+
template.gsub(/\{([^}]+)\}/) do
|
|
15
|
+
token = Regexp.last_match(1)
|
|
16
|
+
name = token.split("=").first.to_s
|
|
17
|
+
short = name.split(".").last.to_s
|
|
18
|
+
value = params[name].to_s
|
|
19
|
+
value = params[short].to_s if value.empty?
|
|
20
|
+
value = params["name"].to_s if value.empty? && (short == "name" || short.start_with?("name_"))
|
|
21
|
+
raise InvalidRequestError, "missing path param #{short}" if value.empty?
|
|
22
|
+
|
|
23
|
+
segments = value.split("/")
|
|
24
|
+
if segments.empty? || segments.any? { |segment| !SEGMENT.match?(segment) }
|
|
25
|
+
raise InvalidRequestError, "invalid path param #{short}"
|
|
26
|
+
end
|
|
27
|
+
segments.map { |segment| CGI.escape(segment).gsub("+", "%20") }.join("/")
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def resolve(base_url, path, query = nil)
|
|
32
|
+
base = base_url.end_with?("/") ? base_url : "#{base_url}/"
|
|
33
|
+
uri = URI.join(base, path)
|
|
34
|
+
origin = URI(base)
|
|
35
|
+
raise InvalidRequestError, "invalid path" if uri.scheme != origin.scheme || uri.host != origin.host
|
|
36
|
+
|
|
37
|
+
parts = []
|
|
38
|
+
(query || {}).each do |key, value|
|
|
39
|
+
next if value.nil? || value.to_s.empty?
|
|
40
|
+
|
|
41
|
+
parts << "#{CGI.escape(key.to_s)}=#{CGI.escape(value.to_s)}"
|
|
42
|
+
end
|
|
43
|
+
uri.query = parts.join("&") unless parts.empty?
|
|
44
|
+
uri
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# A raw substring misses a key once CGI.escape percent-encodes it.
|
|
48
|
+
def query_contains_secret(raw_query, secret)
|
|
49
|
+
query = raw_query.to_s.sub(/\A\?/, "")
|
|
50
|
+
key = secret.to_s
|
|
51
|
+
return false if key.empty? || query.empty?
|
|
52
|
+
escaped = CGI.escape(key)
|
|
53
|
+
return true if query.include?(key) || query.include?(escaped)
|
|
54
|
+
return true if broken_percent?(query)
|
|
55
|
+
|
|
56
|
+
URI.decode_www_form(query).any? { |_name, value| value.include?(key) }
|
|
57
|
+
rescue ArgumentError
|
|
58
|
+
true
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def broken_percent?(query)
|
|
62
|
+
index = 0
|
|
63
|
+
while index < query.length
|
|
64
|
+
unless query[index] == "%"
|
|
65
|
+
index += 1
|
|
66
|
+
next
|
|
67
|
+
end
|
|
68
|
+
digits = query[index + 1, 2].to_s
|
|
69
|
+
return true if digits.length < 2 || digits.match?(/[^0-9a-f]/i)
|
|
70
|
+
|
|
71
|
+
index += 3
|
|
72
|
+
end
|
|
73
|
+
false
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def allowed_upload_url(raw, hosts)
|
|
77
|
+
begin
|
|
78
|
+
uri = URI.parse(raw)
|
|
79
|
+
rescue URI::InvalidURIError
|
|
80
|
+
raise Error, "upload url rejected"
|
|
81
|
+
end
|
|
82
|
+
raise Error, "upload url rejected" if uri.host.nil?
|
|
83
|
+
raise Error, "upload url must be https" unless uri.scheme == "https"
|
|
84
|
+
raise Error, "upload url rejected" if uri.user || uri.password
|
|
85
|
+
|
|
86
|
+
host = uri.host.downcase
|
|
87
|
+
allowed = hosts.any? { |item| host == item || host.end_with?(".#{item}") }
|
|
88
|
+
raise Error, "upload host rejected" unless allowed
|
|
89
|
+
|
|
90
|
+
uri
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def retry_delay_ms(retry_after, reset_after, now_ms = (Time.now.to_r * 1000).round)
|
|
94
|
+
when_ms = parse_time(retry_after) || parse_time(reset_after)
|
|
95
|
+
return 0 if when_ms.nil?
|
|
96
|
+
|
|
97
|
+
[[when_ms - now_ms, 0].max, MAX_DELAY_MS].min
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def parse_time(value)
|
|
101
|
+
text = value.to_s.strip
|
|
102
|
+
return nil if text.empty?
|
|
103
|
+
|
|
104
|
+
parsed = Time.iso8601(text)
|
|
105
|
+
(parsed.to_r * 1000).round
|
|
106
|
+
rescue ArgumentError
|
|
107
|
+
nil
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Signingflow
|
|
4
|
+
module Redact
|
|
5
|
+
SECRET_KEY = /df_sk_[A-Za-z0-9_-]+/
|
|
6
|
+
BEARER = /Bearer\s+\S+/i
|
|
7
|
+
SIGNED_QUERY = /([?&](?:X-Goog-Signature|Signature|sig|token)=)[^&\s"]+/i
|
|
8
|
+
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def redact(value)
|
|
12
|
+
value.to_s
|
|
13
|
+
.gsub(SECRET_KEY, "df_sk_redacted")
|
|
14
|
+
.gsub(BEARER, "Bearer [redacted]")
|
|
15
|
+
.gsub(SIGNED_QUERY, "\\1[redacted]")
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def message(value, limit = 500)
|
|
19
|
+
cleaned = redact(value)
|
|
20
|
+
cleaned.length > limit ? "#{cleaned[0, limit]}…" : cleaned
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def violations(list)
|
|
24
|
+
return nil if list.nil?
|
|
25
|
+
|
|
26
|
+
list.map do |item|
|
|
27
|
+
{ "field" => message(item["field"]), "description" => message(item["description"]) }
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|