edgeway 0.1.0 → 0.3.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 +4 -4
- data/CHANGELOG.md +55 -0
- data/README.md +784 -125
- data/js/cloudflare-d1-sessions.js +68 -0
- data/js/cloudflare-kv.js +47 -0
- data/js/cloudflare-ratelimit.js +31 -0
- data/js/cloudflare-test-database.js +112 -0
- data/js/cloudflare-test-runner.js +76 -0
- data/js/cloudflare.js +144 -22
- data/js/cors.js +15 -16
- data/js/fetch.js +113 -0
- data/js/headers.js +16 -0
- data/js/jspi.js +32 -0
- data/js/jwt.js +6 -40
- data/js/runtime.js +42 -5
- data/lib/edgeway/application.rb +21 -9
- data/lib/edgeway/cache/limits.rb +8 -0
- data/lib/edgeway/cache/memory_store.rb +24 -0
- data/lib/edgeway/cache/null_store.rb +13 -0
- data/lib/edgeway/cache/store.rb +189 -0
- data/lib/edgeway/cache.rb +13 -0
- data/lib/edgeway/cli/build/app_config.rb +39 -0
- data/lib/edgeway/cli/build/app_files.rb +40 -0
- data/lib/edgeway/cli/build/cache_config.rb +108 -0
- data/lib/edgeway/cli/build/database_config.rb +96 -0
- data/lib/edgeway/cli/build/js_files.rb +30 -0
- data/lib/edgeway/cli/build/view_compiler.rb +191 -0
- data/lib/edgeway/cli/build.rb +50 -18
- data/lib/edgeway/cli/cloudflare_test/wasm_test_cli.rb +35 -0
- data/lib/edgeway/cli/cloudflare_test.rb +120 -0
- data/lib/edgeway/cli/generate.rb +174 -26
- data/lib/edgeway/cli/new.rb +1 -1
- data/lib/edgeway/cli/test.rb +28 -9
- data/lib/edgeway/cli/toolchain.rb +5 -0
- data/lib/edgeway/cli.rb +7 -4
- data/lib/edgeway/controller/base.rb +32 -3
- data/lib/edgeway/controller/caching.rb +140 -0
- data/lib/edgeway/controller/callbacks.rb +5 -1
- data/lib/edgeway/controller/dispatcher.rb +11 -1
- data/lib/edgeway/controller/rate_limiting.rb +145 -0
- data/lib/edgeway/controller/rendering.rb +24 -4
- data/lib/edgeway/db/adapter.rb +38 -0
- data/lib/edgeway/db/memory.rb +9 -2
- data/lib/edgeway/db/query_stats.rb +42 -0
- data/lib/edgeway/db/sqlite.rb +73 -9
- data/lib/edgeway/errors.rb +43 -0
- data/lib/edgeway/generators/app/templates/.github/workflows/ci.yml.tt +38 -0
- data/lib/edgeway/generators/app/templates/.ruby-version.tt +1 -0
- data/lib/edgeway/generators/app/templates/README.md.tt +8 -2
- data/lib/edgeway/generators/app/templates/app/views/layouts/application.html.erb +11 -0
- data/lib/edgeway/generators/app/templates/bin/build.tt +1 -1
- data/lib/edgeway/generators/app/templates/bin/deploy.tt +1 -1
- data/lib/edgeway/generators/app/templates/bin/setup.tt +2 -3
- data/lib/edgeway/generators/app/templates/bin/test.tt +7 -0
- data/lib/edgeway/generators/app/templates/config/cache.yml +22 -0
- data/lib/edgeway/generators/app/templates/config/database.yml +25 -2
- data/lib/edgeway/generators/app/templates/mise.toml.tt +1 -1
- data/lib/edgeway/generators/app/templates/public/.assetsignore +1 -0
- data/lib/edgeway/generators/app/templates/wrangler.toml.tt +39 -0
- data/lib/edgeway/http/auth/stub.rb +1 -3
- data/lib/edgeway/http/client.rb +80 -0
- data/lib/edgeway/http/request.rb +7 -22
- data/lib/edgeway/http/stub_transport.rb +38 -0
- data/lib/edgeway/inflector.rb +19 -0
- data/lib/edgeway/logger.rb +33 -5
- data/lib/edgeway/model/attributes.rb +93 -10
- data/lib/edgeway/model/base.rb +50 -5
- data/lib/edgeway/model/connection_registry.rb +25 -0
- data/lib/edgeway/model/macros.rb +5 -1
- data/lib/edgeway/model/persistence.rb +18 -11
- data/lib/edgeway/model/relation.rb +106 -56
- data/lib/edgeway/model/validations.rb +29 -23
- data/lib/edgeway/rate_limit/limiter.rb +9 -0
- data/lib/edgeway/rate_limit/null_limiter.rb +9 -0
- data/lib/edgeway/rate_limit/stub_limiter.rb +24 -0
- data/lib/edgeway/rate_limit.rb +2 -0
- data/lib/edgeway/routing/route.rb +1 -6
- data/lib/edgeway/server.rb +13 -0
- data/lib/edgeway/service/cloudflare/d1.rb +44 -30
- data/lib/edgeway/service/cloudflare/jwt.rb +3 -15
- data/lib/edgeway/service/cloudflare/kv.rb +45 -0
- data/lib/edgeway/service/cloudflare/kv_store.rb +58 -0
- data/lib/edgeway/service/cloudflare/rate_limit.rb +27 -0
- data/lib/edgeway/service/cloudflare/rate_limiter.rb +33 -0
- data/lib/edgeway/service/cloudflare/test_databases.rb +65 -0
- data/lib/edgeway/service/fetch.rb +26 -0
- data/lib/edgeway/service/import_result.rb +24 -0
- data/lib/edgeway/test/sqlite_databases.rb +18 -0
- data/lib/edgeway/test.rb +86 -26
- data/lib/edgeway/version.rb +1 -1
- data/lib/edgeway/view.rb +24 -0
- data/lib/edgeway.rb +9 -0
- metadata +50 -4
- data/lib/edgeway/cli/app_config.rb +0 -35
- data/lib/edgeway/cli/app_files.rb +0 -37
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
module Edgeway
|
|
2
|
+
module Controller
|
|
3
|
+
# HTTP caching helpers for actions:
|
|
4
|
+
#
|
|
5
|
+
# def show
|
|
6
|
+
# todo = Todo.find(params["id"])
|
|
7
|
+
# render json: todo if stale?(etag: todo, last_modified: todo.updated_at)
|
|
8
|
+
# end
|
|
9
|
+
#
|
|
10
|
+
# Only the validators an action names are used; nothing is derived from
|
|
11
|
+
# the response body.
|
|
12
|
+
module Caching
|
|
13
|
+
ETAG_LENGTH = 32
|
|
14
|
+
|
|
15
|
+
def expires_in(seconds, public: false, must_revalidate: false)
|
|
16
|
+
raise ArgumentError, "seconds must be a non-negative Integer" unless Caching.duration?(seconds)
|
|
17
|
+
value = "max-age=#{seconds}, #{public ? "public" : "private"}"
|
|
18
|
+
value += ", must-revalidate" if must_revalidate
|
|
19
|
+
@headers["Cache-Control"] = value
|
|
20
|
+
nil
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def no_store
|
|
24
|
+
@headers["Cache-Control"] = "no-store"
|
|
25
|
+
nil
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Returns true when it answered 304 (the action is done), false
|
|
29
|
+
# otherwise.
|
|
30
|
+
def fresh_when(etag: nil, last_modified: nil, public: false)
|
|
31
|
+
etag_value = etag.nil? ? nil : Caching.etag_for(etag)
|
|
32
|
+
modified_time = Caching.time_for(last_modified)
|
|
33
|
+
@headers["ETag"] = etag_value unless etag_value.nil?
|
|
34
|
+
@headers["Last-Modified"] = modified_time.httpdate unless modified_time.nil?
|
|
35
|
+
set_default_cache_control(public)
|
|
36
|
+
return false unless cacheable_request? && request_fresh?(etag_value, modified_time)
|
|
37
|
+
remove_representation_headers
|
|
38
|
+
head :not_modified
|
|
39
|
+
true
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# fresh_when, answering true when the action should go on to render.
|
|
43
|
+
def stale?(etag: nil, last_modified: nil, public: false)
|
|
44
|
+
!fresh_when(etag: etag, last_modified: last_modified, public: public)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Integer() gives Spinel a type for a parameter no call site types.
|
|
48
|
+
def self.duration?(seconds)
|
|
49
|
+
seconds.is_a?(Integer) && Integer(seconds) >= 0
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Weak, because a proxy may recompress the body and weaken a strong
|
|
53
|
+
# validator on the way.
|
|
54
|
+
def self.etag_for(source)
|
|
55
|
+
digest = Digest::SHA256.hexdigest(etag_source(source))
|
|
56
|
+
"W/\"#{digest[0, ETAG_LENGTH]}\""
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def self.etag_source(source)
|
|
60
|
+
if source.is_a?(Model::Base)
|
|
61
|
+
model_source(source)
|
|
62
|
+
elsif source.is_a?(Model::Relation)
|
|
63
|
+
list_source(source.to_a)
|
|
64
|
+
elsif source.is_a?(Array)
|
|
65
|
+
list_source(source)
|
|
66
|
+
else
|
|
67
|
+
source.to_s
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def self.model_source(model)
|
|
72
|
+
updated_at = model.as_json["updated_at"].to_s
|
|
73
|
+
raise ArgumentError, "#{model.class.name} has no updated_at (add timestamps)" if updated_at.empty?
|
|
74
|
+
"#{model.class.name}/#{model.id}/#{updated_at}"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def self.list_source(items)
|
|
78
|
+
"[" + items.map { |item| etag_source(item) }.join(",") + "]"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def self.time_for(value)
|
|
82
|
+
return nil if value.nil?
|
|
83
|
+
return value.getutc if value.is_a?(Time)
|
|
84
|
+
raise ArgumentError, "last_modified must be a Time or an ISO 8601 String" unless value.is_a?(String)
|
|
85
|
+
begin
|
|
86
|
+
Time.iso8601(value).utc
|
|
87
|
+
rescue ArgumentError
|
|
88
|
+
raise ArgumentError, "last_modified is not an ISO 8601 String: #{value.inspect}"
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# The validators match the ones the client sent (RFC 9110 13.1.2,
|
|
93
|
+
# 13.1.3): If-None-Match decides when it is present, and
|
|
94
|
+
# If-Modified-Since is then ignored.
|
|
95
|
+
def self.fresh?(none_match, modified_since, etag, last_modified)
|
|
96
|
+
return etag_listed?(none_match, etag) unless none_match.empty?
|
|
97
|
+
return false if modified_since.empty? || last_modified.nil?
|
|
98
|
+
since = parse_http_date(modified_since)
|
|
99
|
+
!since.nil? && last_modified.to_i <= since.to_i
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def self.etag_listed?(header, etag)
|
|
103
|
+
return false if etag.nil?
|
|
104
|
+
candidates = header.split(",").map { |candidate| candidate.strip }
|
|
105
|
+
return true if candidates.include?("*")
|
|
106
|
+
candidates.any? { |candidate| candidate.delete_prefix("W/") == etag.delete_prefix("W/") }
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def self.parse_http_date(text)
|
|
110
|
+
Time.httpdate(text).utc
|
|
111
|
+
rescue ArgumentError
|
|
112
|
+
nil
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
private
|
|
116
|
+
|
|
117
|
+
def set_default_cache_control(public)
|
|
118
|
+
return if header_set?("Cache-Control")
|
|
119
|
+
@headers["Cache-Control"] = "max-age=0, #{public ? "public" : "private"}, must-revalidate"
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def header_set?(name)
|
|
123
|
+
@headers.keys.any? { |key| key.downcase == name.downcase }
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def remove_representation_headers
|
|
127
|
+
@headers.delete("Content-Type")
|
|
128
|
+
@headers.delete("X-Content-Type-Options")
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def cacheable_request?
|
|
132
|
+
@request.method == "GET" || @request.method == "HEAD"
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def request_fresh?(etag_value, modified_time)
|
|
136
|
+
Caching.fresh?(@request.header("If-None-Match"), @request.header("If-Modified-Since"), etag_value, modified_time)
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
@@ -9,6 +9,10 @@ module Edgeway
|
|
|
9
9
|
@except = except
|
|
10
10
|
end
|
|
11
11
|
|
|
12
|
+
def run(controller)
|
|
13
|
+
controller.send(@name)
|
|
14
|
+
end
|
|
15
|
+
|
|
12
16
|
def applies?(action)
|
|
13
17
|
return false if @except.include?(action)
|
|
14
18
|
@only.empty? || @only.include?(action)
|
|
@@ -19,7 +23,7 @@ module Edgeway
|
|
|
19
23
|
def run_before_actions(action)
|
|
20
24
|
self.class.all_before_actions.each do |callback|
|
|
21
25
|
next unless callback.applies?(action)
|
|
22
|
-
|
|
26
|
+
callback.run(self)
|
|
23
27
|
return false if performed?
|
|
24
28
|
end
|
|
25
29
|
true
|
|
@@ -15,6 +15,7 @@ module Edgeway
|
|
|
15
15
|
klass = controller_class(route)
|
|
16
16
|
raise RoutingError, "no controller for #{route.controller_path}" if klass.nil?
|
|
17
17
|
controller = klass.new(request)
|
|
18
|
+
controller.action_name = route.action
|
|
18
19
|
# A route whose action isn't defined (or is one of Base's own public
|
|
19
20
|
# methods) is a 404, as in Rails.
|
|
20
21
|
raise RoutingError, "no route for #{request.method} #{request.path}" unless controller.action?(route.action)
|
|
@@ -26,7 +27,7 @@ module Edgeway
|
|
|
26
27
|
# a route can't reach a private method.
|
|
27
28
|
action = route.action.to_sym
|
|
28
29
|
controller.public_send(action) if controller.run_before_actions(route.action)
|
|
29
|
-
controller
|
|
30
|
+
implicit_render(controller, route) unless controller.performed?
|
|
30
31
|
rescue => e
|
|
31
32
|
raise e unless controller.rescue_with_handler(e)
|
|
32
33
|
# A handler that rendered nothing answers 204, as an action does.
|
|
@@ -34,6 +35,15 @@ module Edgeway
|
|
|
34
35
|
end
|
|
35
36
|
controller.to_response
|
|
36
37
|
end
|
|
38
|
+
|
|
39
|
+
def self.implicit_render(controller, route)
|
|
40
|
+
content = controller._render_view(route.action)
|
|
41
|
+
if content.nil?
|
|
42
|
+
controller.head(:no_content)
|
|
43
|
+
else
|
|
44
|
+
controller.render(html: View::SafeHTML.new(content))
|
|
45
|
+
end
|
|
46
|
+
end
|
|
37
47
|
end
|
|
38
48
|
end
|
|
39
49
|
end
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
module Edgeway
|
|
2
|
+
module Controller
|
|
3
|
+
@rate_limiter = RateLimit::NullLimiter.new
|
|
4
|
+
|
|
5
|
+
def self.rate_limiter = @rate_limiter
|
|
6
|
+
|
|
7
|
+
def self.rate_limiter=(limiter)
|
|
8
|
+
@rate_limiter = limiter
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
# Rate limiting through a limiter binding:
|
|
12
|
+
#
|
|
13
|
+
# class PostsController < ApplicationController
|
|
14
|
+
# rate_limit "WRITE_LIMITER", only: [:create], retry_after: 10
|
|
15
|
+
# end
|
|
16
|
+
#
|
|
17
|
+
# It is a before_action. Over the limit it raises TooManyRequests (429).
|
|
18
|
+
# The limit and the period are set on the binding, not here. A by: method is
|
|
19
|
+
# never a route's action (Base.rate_limit adds it to the non-actions).
|
|
20
|
+
module RateLimiting
|
|
21
|
+
class Rule < Callback
|
|
22
|
+
attr_reader :binding_name, :by, :scope_name, :retry_after
|
|
23
|
+
|
|
24
|
+
def self.build(binding_name, by, name, retry_after, only, except)
|
|
25
|
+
raise ArgumentError, "the limiter binding name must be a non-empty String" unless binding_name.is_a?(String) && !binding_name.empty?
|
|
26
|
+
raise ArgumentError, "by: must be a Symbol" unless by.nil? || by.is_a?(Symbol)
|
|
27
|
+
raise ArgumentError, "name: must be a String" unless name.nil? || name.is_a?(String)
|
|
28
|
+
raise ArgumentError, "retry_after: must be a positive Integer" unless retry_after.nil? || RateLimiting.positive_integer?(retry_after)
|
|
29
|
+
new(binding_name, by, name, retry_after.nil? ? 0 : retry_after, only.map(&:to_s), except.map(&:to_s))
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def initialize(binding_name, by, name, retry_after, only, except)
|
|
33
|
+
super("rate_limit", only, except)
|
|
34
|
+
@binding_name = binding_name
|
|
35
|
+
@by = by
|
|
36
|
+
@scope_name = name
|
|
37
|
+
@retry_after = retry_after
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def run(controller)
|
|
41
|
+
controller.send(:enforce_rate_limit, self)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
IPV6_GROUPS = 8
|
|
46
|
+
IPV6_PREFIX_GROUPS = 4
|
|
47
|
+
IPV4_OCTETS = 4
|
|
48
|
+
IPV4_MAPPED_ZERO_GROUPS = 5
|
|
49
|
+
IPV4_MAPPED_MARKER_GROUP = 5
|
|
50
|
+
IPV4_HIGH_GROUP = 6
|
|
51
|
+
IPV4_LOW_GROUP = 7
|
|
52
|
+
HEX_DIGITS = "0123456789abcdef"
|
|
53
|
+
|
|
54
|
+
# An IPv6 client usually has a whole /64, so its address is keyed by
|
|
55
|
+
# that prefix; an IPv4 address (or one that does not parse) is kept.
|
|
56
|
+
def self.address_key(address)
|
|
57
|
+
return address unless address.include?(":")
|
|
58
|
+
|
|
59
|
+
text = address.downcase.split("%")[0].to_s
|
|
60
|
+
groups = ipv6_groups(embedded_ipv4_as_hex(text))
|
|
61
|
+
return address if groups.nil?
|
|
62
|
+
|
|
63
|
+
ipv4_mapped?(groups) ? ipv4_from_groups(groups) : groups[0, IPV6_PREFIX_GROUPS].join(":") + "::/64"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# "::ffff:192.0.2.1" -> "::ffff:c000:201", so the dotted tail parses as two groups.
|
|
67
|
+
def self.embedded_ipv4_as_hex(text)
|
|
68
|
+
colon = text.rindex(":").to_i
|
|
69
|
+
octets = text[colon + 1, text.length].to_s.split(".", -1)
|
|
70
|
+
return text unless octets.length == IPV4_OCTETS && octets.all? { |octet| decimal_octet?(octet) }
|
|
71
|
+
|
|
72
|
+
values = octets.map { |octet| octet.to_i }
|
|
73
|
+
high = (values[0] * 256 + values[1]).to_s(16)
|
|
74
|
+
low = (values[2] * 256 + values[3]).to_s(16)
|
|
75
|
+
text[0, colon + 1].to_s + high + ":" + low
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def self.decimal_octet?(octet)
|
|
79
|
+
octet.length >= 1 && octet.length <= 3 && octet.each_char.all? { |digit| "0123456789".include?(digit) } && octet.to_i <= 255
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def self.ipv4_mapped?(groups)
|
|
83
|
+
groups[0, IPV4_MAPPED_ZERO_GROUPS].all? { |group| group == "0" } && groups[IPV4_MAPPED_MARKER_GROUP] == "ffff"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def self.ipv4_from_groups(groups)
|
|
87
|
+
high = groups[IPV4_HIGH_GROUP].to_i(16)
|
|
88
|
+
low = groups[IPV4_LOW_GROUP].to_i(16)
|
|
89
|
+
[high / 256, high % 256, low / 256, low % 256].join(".")
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def self.ipv6_groups(text)
|
|
93
|
+
halves = text.split("::", -1)
|
|
94
|
+
return nil if halves.empty? || halves.length > 2
|
|
95
|
+
|
|
96
|
+
head = group_list(halves[0])
|
|
97
|
+
tail = halves.length == 2 ? group_list(halves[1]) : []
|
|
98
|
+
return nil if head.nil? || tail.nil?
|
|
99
|
+
|
|
100
|
+
filler = IPV6_GROUPS - head.length - tail.length
|
|
101
|
+
return nil if halves.length == 1 ? filler != 0 : filler < 1
|
|
102
|
+
|
|
103
|
+
head + Array.new(filler, "0") + tail
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def self.group_list(text)
|
|
107
|
+
return [] if text.empty?
|
|
108
|
+
|
|
109
|
+
groups = text.split(":", -1).map { |group| normalized_group(group) }
|
|
110
|
+
groups.include?("") ? nil : groups
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def self.normalized_group(group)
|
|
114
|
+
return "" if group.empty? || group.length > 4 || !group.each_char.all? { |digit| HEX_DIGITS.include?(digit) }
|
|
115
|
+
|
|
116
|
+
group.to_i(16).to_s(16)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Integer() gives Spinel a type for a parameter no call site types.
|
|
120
|
+
def self.positive_integer?(value)
|
|
121
|
+
value.is_a?(Integer) && Integer(value) > 0
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
private
|
|
125
|
+
|
|
126
|
+
def enforce_rate_limit(rule)
|
|
127
|
+
return unless Edgeway::Controller.rate_limiter.exceeded?(rule.binding_name, rate_limit_key_for(rule))
|
|
128
|
+
|
|
129
|
+
raise TooManyRequests.new("rate limit exceeded (#{rule.binding_name})", rule.retry_after)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def rate_limit_key_for(rule)
|
|
133
|
+
scope = rule.scope_name.nil? ? self.class.name : rule.scope_name
|
|
134
|
+
"#{scope}:#{rule.by.nil? ? default_rate_limit_key : send(rule.by).to_s}"
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def default_rate_limit_key
|
|
138
|
+
subject = authenticated? ? current_claims["sub"].to_s : ""
|
|
139
|
+
return "user:#{subject}" unless subject.empty?
|
|
140
|
+
|
|
141
|
+
"ip:#{RateLimiting.address_key(request.remote_addr)}"
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
@@ -10,6 +10,8 @@ module Edgeway
|
|
|
10
10
|
"service_unavailable" => 503
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
UNSET = Object.new
|
|
14
|
+
|
|
13
15
|
def self.json_response(status, json_text)
|
|
14
16
|
HTTP::Response.json(status, json_text)
|
|
15
17
|
end
|
|
@@ -39,10 +41,28 @@ module Edgeway
|
|
|
39
41
|
end
|
|
40
42
|
end
|
|
41
43
|
|
|
42
|
-
def
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
44
|
+
def self.html_content(value)
|
|
45
|
+
value.is_a?(View::SafeHTML) ? value.to_s : View.escape(value)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def render(template = nil, json: UNSET, html: UNSET, status: 200, layout: true)
|
|
49
|
+
code = Rendering.status_code(status)
|
|
50
|
+
if !json.equal?(UNSET)
|
|
51
|
+
body = JSON.generate(Rendering.json_value(json)) + "\n"
|
|
52
|
+
@headers["Content-Type"] = "application/json"
|
|
53
|
+
else
|
|
54
|
+
content = html.equal?(UNSET) ? nil : Rendering.html_content(html)
|
|
55
|
+
if content.nil?
|
|
56
|
+
name = template.nil? ? @action_name : template.to_s
|
|
57
|
+
content = _render_view(name)
|
|
58
|
+
raise MissingTemplate, "no template #{name.inspect} for #{self.class.name}" if content.nil?
|
|
59
|
+
end
|
|
60
|
+
body = layout ? _render_layout(content) : content
|
|
61
|
+
@headers["Content-Type"] = "text/html; charset=utf-8"
|
|
62
|
+
@headers["X-Content-Type-Options"] = "nosniff"
|
|
63
|
+
end
|
|
64
|
+
@status = code
|
|
65
|
+
@response_body = body
|
|
46
66
|
@performed = true
|
|
47
67
|
end
|
|
48
68
|
|
data/lib/edgeway/db/adapter.rb
CHANGED
|
@@ -1,6 +1,18 @@
|
|
|
1
|
+
require_relative "query_stats"
|
|
2
|
+
|
|
1
3
|
module Edgeway
|
|
2
4
|
module DB
|
|
3
5
|
class Adapter
|
|
6
|
+
MAX_BINDS = 100
|
|
7
|
+
|
|
8
|
+
def query_stats
|
|
9
|
+
@query_stats ||= QueryStats.new
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def record_meta(meta)
|
|
13
|
+
query_stats.add(meta)
|
|
14
|
+
end
|
|
15
|
+
|
|
4
16
|
def select_all(sql, binds)
|
|
5
17
|
raise Error, "#{self.class} does not define select_all"
|
|
6
18
|
end
|
|
@@ -9,6 +21,10 @@ module Edgeway
|
|
|
9
21
|
raise Error, "#{self.class} does not define execute"
|
|
10
22
|
end
|
|
11
23
|
|
|
24
|
+
def exec(sql)
|
|
25
|
+
raise Error, "#{self.class} does not define exec"
|
|
26
|
+
end
|
|
27
|
+
|
|
12
28
|
def batch(statements)
|
|
13
29
|
raise Error, "#{self.class} does not define batch"
|
|
14
30
|
end
|
|
@@ -19,11 +35,33 @@ module Edgeway
|
|
|
19
35
|
rows[0]
|
|
20
36
|
end
|
|
21
37
|
|
|
38
|
+
def last_insert_id_of(value)
|
|
39
|
+
return 0 if value.nil?
|
|
40
|
+
number = value.to_i
|
|
41
|
+
number > Edgeway::Model::INT_MAX || number < Edgeway::Model::INT_MIN ? value.to_s : number
|
|
42
|
+
rescue RangeError
|
|
43
|
+
value.to_s
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def check_binds(binds)
|
|
47
|
+
return if binds.size <= MAX_BINDS
|
|
48
|
+
raise ArgumentError, "too many bind parameters: #{binds.size} (at most #{MAX_BINDS})"
|
|
49
|
+
end
|
|
50
|
+
|
|
22
51
|
def fail_with(message)
|
|
23
52
|
raise RecordNotUnique, message if message.include?("UNIQUE constraint failed")
|
|
53
|
+
raise NotNullViolation, message if message.include?("NOT NULL constraint failed")
|
|
54
|
+
raise InvalidForeignKey, message if message.include?("FOREIGN KEY constraint failed")
|
|
55
|
+
raise CheckViolation, message if message.include?("CHECK constraint failed")
|
|
24
56
|
raise DatabaseError, message
|
|
25
57
|
end
|
|
26
58
|
|
|
59
|
+
def raise_error_result(res, prefix = "")
|
|
60
|
+
message = "#{prefix}#{res["error"]}"
|
|
61
|
+
raise DatabaseUnavailable, message if res.key?("unavailable")
|
|
62
|
+
fail_with(message)
|
|
63
|
+
end
|
|
64
|
+
|
|
27
65
|
def supports_returning? = true
|
|
28
66
|
def placeholder(n) = "?"
|
|
29
67
|
def quote_boolean(value) = value ? 1 : 0
|
data/lib/edgeway/db/memory.rb
CHANGED
|
@@ -32,8 +32,8 @@ module Edgeway
|
|
|
32
32
|
end
|
|
33
33
|
|
|
34
34
|
# Makes the next query or execute whose SQL contains `containing` (any,
|
|
35
|
-
# by default) fail with this database message, after logging it, as
|
|
36
|
-
# would: "UNIQUE constraint failed: t.c" is a RecordNotUnique.
|
|
35
|
+
# by default) fail with this database message, after logging it, as the
|
|
36
|
+
# database would: "UNIQUE constraint failed: t.c" is a RecordNotUnique.
|
|
37
37
|
def fail_next(message, containing = "")
|
|
38
38
|
@failure = message
|
|
39
39
|
@failure_on = containing
|
|
@@ -42,18 +42,25 @@ module Edgeway
|
|
|
42
42
|
def supports_returning? = @returning
|
|
43
43
|
|
|
44
44
|
def select_all(sql, binds)
|
|
45
|
+
check_binds(binds)
|
|
45
46
|
record(sql, binds)
|
|
46
47
|
@queue.empty? ? [] : @queue.shift
|
|
47
48
|
end
|
|
48
49
|
|
|
49
50
|
def execute(sql, binds)
|
|
51
|
+
check_binds(binds)
|
|
50
52
|
record(sql, binds)
|
|
51
53
|
id = @next_id
|
|
52
54
|
@next_id += 1
|
|
53
55
|
{ "changes" => @changes, "last_insert_id" => id }
|
|
54
56
|
end
|
|
55
57
|
|
|
58
|
+
def exec(sql)
|
|
59
|
+
record(sql, [])
|
|
60
|
+
end
|
|
61
|
+
|
|
56
62
|
def batch(statements)
|
|
63
|
+
statements.each { |s| check_binds(s[1]) }
|
|
57
64
|
@log << "BATCH #{statements.size}"
|
|
58
65
|
statements.map { |s| execute(s[0], s[1]) }
|
|
59
66
|
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
module Edgeway
|
|
2
|
+
module DB
|
|
3
|
+
# What the database reported for one request.
|
|
4
|
+
class QueryStats
|
|
5
|
+
MAX_COUNT = 2147483647
|
|
6
|
+
|
|
7
|
+
attr_reader :queries, :rows_read, :rows_written, :duration_ms, :served_by_region, :served_by_primary
|
|
8
|
+
|
|
9
|
+
def initialize
|
|
10
|
+
@queries = 0
|
|
11
|
+
@rows_read = 0
|
|
12
|
+
@rows_written = 0
|
|
13
|
+
@duration_ms = 0.0
|
|
14
|
+
@served_by_region = ""
|
|
15
|
+
@served_by_primary = true
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def add(meta)
|
|
19
|
+
rows_read = capped_sum(@rows_read, meta["rows_read"])
|
|
20
|
+
rows_written = capped_sum(@rows_written, meta["rows_written"])
|
|
21
|
+
duration_ms = @duration_ms + meta["duration"].to_f
|
|
22
|
+
@rows_read = rows_read
|
|
23
|
+
@rows_written = rows_written
|
|
24
|
+
@duration_ms = duration_ms
|
|
25
|
+
@served_by_region = meta["served_by_region"].to_s if meta.key?("served_by_region")
|
|
26
|
+
@served_by_primary = meta["served_by_primary"] != false if meta.key?("served_by_primary")
|
|
27
|
+
@queries = capped_sum(@queries, 1)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def log_fields
|
|
31
|
+
{ "queries" => @queries, "rows_read" => @rows_read, "rows_written" => @rows_written, "db_ms" => @duration_ms.round(2) }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def capped_sum(total, count)
|
|
37
|
+
return MAX_COUNT if count.to_i > MAX_COUNT - total
|
|
38
|
+
total + count.to_i
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
data/lib/edgeway/db/sqlite.rb
CHANGED
|
@@ -20,6 +20,7 @@ module Edgeway
|
|
|
20
20
|
ffi_func :sqlite3_prepare_v2, [:ptr, :str, :int, :ptr, :ptr], :int
|
|
21
21
|
ffi_func :sqlite3_bind_text, [:ptr, :int, :str, :int, :ptr], :int
|
|
22
22
|
ffi_func :sqlite3_bind_int64, [:ptr, :int, :long], :int
|
|
23
|
+
ffi_func :sqlite3_bind_blob, [:ptr, :int, :str, :int, :ptr], :int
|
|
23
24
|
ffi_func :sqlite3_bind_double, [:ptr, :int, :double], :int
|
|
24
25
|
ffi_func :sqlite3_bind_null, [:ptr, :int], :int
|
|
25
26
|
ffi_func :sqlite3_step, [:ptr], :int
|
|
@@ -35,6 +36,35 @@ module Edgeway
|
|
|
35
36
|
ffi_func :sqlite3_changes, [:ptr], :int
|
|
36
37
|
ffi_func :sqlite3_libversion_number, [], :int
|
|
37
38
|
|
|
39
|
+
# A BLOB can hold NUL, which would cut a :str result short, so it is read
|
|
40
|
+
# as hex text.
|
|
41
|
+
ffi_source <<~C
|
|
42
|
+
#include <stdlib.h>
|
|
43
|
+
|
|
44
|
+
extern const void *sqlite3_column_blob(void *statement, int column);
|
|
45
|
+
extern int sqlite3_column_bytes(void *statement, int column);
|
|
46
|
+
|
|
47
|
+
static char *hex;
|
|
48
|
+
static int hex_capacity;
|
|
49
|
+
|
|
50
|
+
const char *column_hex(void *statement, int column) {
|
|
51
|
+
const unsigned char *bytes = sqlite3_column_blob(statement, column);
|
|
52
|
+
int length = sqlite3_column_bytes(statement, column);
|
|
53
|
+
if (2 * length + 1 > hex_capacity) {
|
|
54
|
+
hex = realloc(hex, 2 * length + 1);
|
|
55
|
+
if (!hex) abort();
|
|
56
|
+
hex_capacity = 2 * length + 1;
|
|
57
|
+
}
|
|
58
|
+
for (int i = 0; i < length; i++) {
|
|
59
|
+
hex[2 * i] = "0123456789abcdef"[bytes[i] >> 4];
|
|
60
|
+
hex[2 * i + 1] = "0123456789abcdef"[bytes[i] & 15];
|
|
61
|
+
}
|
|
62
|
+
hex[2 * length] = 0;
|
|
63
|
+
return hex;
|
|
64
|
+
}
|
|
65
|
+
C
|
|
66
|
+
ffi_func :column_hex, [:ptr, :int], :str
|
|
67
|
+
|
|
38
68
|
ffi_buffer :db_out, 8
|
|
39
69
|
ffi_buffer :stmt_out, 8
|
|
40
70
|
ffi_read_ptr :read_ptr, 0
|
|
@@ -43,6 +73,7 @@ module Edgeway
|
|
|
43
73
|
class SQLiteAdapter < Adapter
|
|
44
74
|
INTEGER = 1
|
|
45
75
|
FLOAT = 2
|
|
76
|
+
BLOB = 4
|
|
46
77
|
NULL = 5
|
|
47
78
|
|
|
48
79
|
attr_reader :log
|
|
@@ -63,6 +94,7 @@ module Edgeway
|
|
|
63
94
|
def supports_returning? = @returning
|
|
64
95
|
|
|
65
96
|
def select_all(sql, binds)
|
|
97
|
+
check_binds(binds)
|
|
66
98
|
stmt = start(sql, binds)
|
|
67
99
|
rows = []
|
|
68
100
|
rc = SQLiteFFI.sqlite3_step(stmt)
|
|
@@ -75,26 +107,25 @@ module Edgeway
|
|
|
75
107
|
end
|
|
76
108
|
|
|
77
109
|
def execute(sql, binds)
|
|
110
|
+
check_binds(binds)
|
|
78
111
|
stmt = start(sql, binds)
|
|
79
112
|
rc = SQLiteFFI.sqlite3_step(stmt)
|
|
80
113
|
while rc == SQLiteFFI::ROW
|
|
81
114
|
rc = SQLiteFFI.sqlite3_step(stmt)
|
|
82
115
|
end
|
|
83
116
|
finish(stmt, rc)
|
|
84
|
-
{ "changes" => SQLiteFFI.sqlite3_changes(@db), "last_insert_id" => SQLiteFFI.sqlite3_last_insert_rowid(@db) }
|
|
117
|
+
{ "changes" => SQLiteFFI.sqlite3_changes(@db), "last_insert_id" => last_insert_id_of(SQLiteFFI.sqlite3_last_insert_rowid(@db)) }
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def exec(sql)
|
|
121
|
+
@log << "#{sql} | []"
|
|
122
|
+
in_transaction { sql.split("\n").each { |line| run_script(line) unless line.strip.empty? } }
|
|
85
123
|
end
|
|
86
124
|
|
|
87
125
|
def batch(statements)
|
|
88
126
|
@log << "BATCH #{statements.size}"
|
|
89
|
-
run_script("BEGIN")
|
|
90
127
|
results = []
|
|
91
|
-
|
|
92
|
-
statements.each { |s| results << execute(s[0], s[1]) }
|
|
93
|
-
rescue => e
|
|
94
|
-
run_script("ROLLBACK")
|
|
95
|
-
raise e
|
|
96
|
-
end
|
|
97
|
-
run_script("COMMIT")
|
|
128
|
+
in_transaction { statements.each { |s| results << execute(s[0], s[1]) } }
|
|
98
129
|
results
|
|
99
130
|
end
|
|
100
131
|
|
|
@@ -113,6 +144,17 @@ module Edgeway
|
|
|
113
144
|
|
|
114
145
|
private
|
|
115
146
|
|
|
147
|
+
def in_transaction
|
|
148
|
+
run_script("BEGIN")
|
|
149
|
+
begin
|
|
150
|
+
yield
|
|
151
|
+
rescue => e
|
|
152
|
+
run_script("ROLLBACK")
|
|
153
|
+
raise e
|
|
154
|
+
end
|
|
155
|
+
run_script("COMMIT")
|
|
156
|
+
end
|
|
157
|
+
|
|
116
158
|
def run_script(sql_text)
|
|
117
159
|
rc = SQLiteFFI.sqlite3_exec(@db, sql_text, nil, nil, nil)
|
|
118
160
|
fail_with(SQLiteFFI.sqlite3_errmsg(@db)) if rc != SQLiteFFI::OK
|
|
@@ -122,6 +164,7 @@ module Edgeway
|
|
|
122
164
|
# finalized before the error is raised.
|
|
123
165
|
def start(sql, binds)
|
|
124
166
|
@log << "#{sql} | #{JSON.generate(binds)}"
|
|
167
|
+
check_blob_binds(binds)
|
|
125
168
|
rc = SQLiteFFI.sqlite3_prepare_v2(@db, sql, -1, SQLiteFFI.stmt_out, nil)
|
|
126
169
|
if rc != SQLiteFFI::OK
|
|
127
170
|
message = SQLiteFFI.sqlite3_errmsg(@db)
|
|
@@ -148,6 +191,8 @@ module Edgeway
|
|
|
148
191
|
SQLiteFFI.sqlite3_bind_int64(stmt, n, value)
|
|
149
192
|
elsif value.is_a?(Float)
|
|
150
193
|
SQLiteFFI.sqlite3_bind_double(stmt, n, value)
|
|
194
|
+
elsif value.is_a?(Hash)
|
|
195
|
+
bind_blob(stmt, n, value)
|
|
151
196
|
elsif value == true
|
|
152
197
|
SQLiteFFI.sqlite3_bind_int64(stmt, n, 1)
|
|
153
198
|
elsif value == false
|
|
@@ -158,6 +203,19 @@ module Edgeway
|
|
|
158
203
|
end
|
|
159
204
|
end
|
|
160
205
|
|
|
206
|
+
def check_blob_binds(binds)
|
|
207
|
+
binds.each do |value|
|
|
208
|
+
next unless value.is_a?(Hash)
|
|
209
|
+
bytes = value.key?("$blob") ? Edgeway::Model.cast(:binary, value) : nil
|
|
210
|
+
raise ArgumentError, "a Hash bind must be {\"$blob\" => base64}: #{value}" if bytes.nil?
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def bind_blob(statement, position, value)
|
|
215
|
+
bytes = Edgeway::Model.cast(:binary, value).to_s
|
|
216
|
+
SQLiteFFI.sqlite3_bind_blob(statement, position, bytes, bytes.bytesize, -1)
|
|
217
|
+
end
|
|
218
|
+
|
|
161
219
|
# Finalizes the statement; raises if its last step failed.
|
|
162
220
|
def finish(stmt, rc)
|
|
163
221
|
message = rc == SQLiteFFI::DONE ? "" : SQLiteFFI.sqlite3_errmsg(@db)
|
|
@@ -165,6 +223,10 @@ module Edgeway
|
|
|
165
223
|
fail_with(message) if rc != SQLiteFFI::DONE
|
|
166
224
|
end
|
|
167
225
|
|
|
226
|
+
def read_blob(statement, column)
|
|
227
|
+
Edgeway::Model.blob([SQLiteFFI.column_hex(statement, column)].pack("H*"))
|
|
228
|
+
end
|
|
229
|
+
|
|
168
230
|
def read_row(stmt)
|
|
169
231
|
row = {}
|
|
170
232
|
n = SQLiteFFI.sqlite3_column_count(stmt)
|
|
@@ -178,6 +240,8 @@ module Edgeway
|
|
|
178
240
|
row[name] = SQLiteFFI.sqlite3_column_int64(stmt, i)
|
|
179
241
|
elsif type == FLOAT
|
|
180
242
|
row[name] = SQLiteFFI.sqlite3_column_double(stmt, i)
|
|
243
|
+
elsif type == BLOB
|
|
244
|
+
row[name] = read_blob(stmt, i)
|
|
181
245
|
else
|
|
182
246
|
row[name] = SQLiteFFI.sqlite3_column_text(stmt, i)
|
|
183
247
|
end
|