discord_store 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 +60 -0
- data/LICENSE.txt +21 -0
- data/README.md +439 -0
- data/lib/active_record/connection_adapters/discord_adapter.rb +271 -0
- data/lib/active_storage/service/discord_service.rb +232 -0
- data/lib/discord_store/blob_store.rb +453 -0
- data/lib/discord_store/channel_shard.rb +73 -0
- data/lib/discord_store/cipher.rb +195 -0
- data/lib/discord_store/codec.rb +231 -0
- data/lib/discord_store/configuration.rb +197 -0
- data/lib/discord_store/errors.rb +92 -0
- data/lib/discord_store/guild_limits.rb +117 -0
- data/lib/discord_store/journal.rb +262 -0
- data/lib/discord_store/kv.rb +387 -0
- data/lib/discord_store/log/record.rb +88 -0
- data/lib/discord_store/log.rb +299 -0
- data/lib/discord_store/railtie.rb +27 -0
- data/lib/discord_store/replay.rb +136 -0
- data/lib/discord_store/snowflake.rb +109 -0
- data/lib/discord_store/tasks.rake +92 -0
- data/lib/discord_store/transport/bucket.rb +141 -0
- data/lib/discord_store/transport/fake.rb +394 -0
- data/lib/discord_store/transport/http.rb +174 -0
- data/lib/discord_store/transport/quota.rb +138 -0
- data/lib/discord_store/transport/rate_limiter.rb +148 -0
- data/lib/discord_store/transport/rest.rb +361 -0
- data/lib/discord_store/version.rb +5 -0
- data/lib/discord_store.rb +125 -0
- metadata +97 -0
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DiscordStore
|
|
4
|
+
module Transport
|
|
5
|
+
# One Discord rate-limit bucket.
|
|
6
|
+
#
|
|
7
|
+
# Discord does not publish its per-route limits; it reports them, per
|
|
8
|
+
# response, in the X-RateLimit-* headers, and groups routes into opaque
|
|
9
|
+
# buckets identified by X-RateLimit-Bucket. Several routes can share one
|
|
10
|
+
# bucket, so the limiter learns the mapping at runtime rather than assuming
|
|
11
|
+
# a table of constants that will be wrong by next quarter.
|
|
12
|
+
#
|
|
13
|
+
# The limit that dominates this library is five messages per five seconds
|
|
14
|
+
# per channel. That is the number that caps a naive Discord-backed store,
|
|
15
|
+
# and the only real answer to it is to write to more than one channel —
|
|
16
|
+
# which is why {ChannelShard} exists.
|
|
17
|
+
class Bucket
|
|
18
|
+
UNKNOWN = nil
|
|
19
|
+
|
|
20
|
+
attr_reader :hash_key
|
|
21
|
+
|
|
22
|
+
def initialize(hash_key: nil, clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
|
|
23
|
+
@hash_key = hash_key
|
|
24
|
+
@clock = clock
|
|
25
|
+
@limit = UNKNOWN
|
|
26
|
+
@remaining = UNKNOWN
|
|
27
|
+
@reset_at = nil
|
|
28
|
+
@mutex = Mutex.new
|
|
29
|
+
@condition = ConditionVariable.new
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Blocks until this bucket has room, then claims a slot optimistically.
|
|
33
|
+
# The claim is provisional: {#update} reconciles it against whatever the
|
|
34
|
+
# server actually reports, and the server's number always wins.
|
|
35
|
+
#
|
|
36
|
+
# @param timeout [Numeric, nil]
|
|
37
|
+
# @return [void]
|
|
38
|
+
# @raise [QuotaTimeoutError]
|
|
39
|
+
def acquire(timeout: nil)
|
|
40
|
+
deadline = timeout && (@clock.call + timeout)
|
|
41
|
+
|
|
42
|
+
@mutex.synchronize do
|
|
43
|
+
loop do
|
|
44
|
+
expire_window
|
|
45
|
+
|
|
46
|
+
# Nothing learned yet: let one request through so there is something
|
|
47
|
+
# to learn from.
|
|
48
|
+
if @remaining == UNKNOWN
|
|
49
|
+
@remaining = UNKNOWN
|
|
50
|
+
return
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
if @remaining.positive?
|
|
54
|
+
@remaining -= 1
|
|
55
|
+
return
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
wait = [@reset_at.to_f - @clock.call, 0.0].max
|
|
59
|
+
|
|
60
|
+
if deadline
|
|
61
|
+
remaining_time = deadline - @clock.call
|
|
62
|
+
if remaining_time <= 0
|
|
63
|
+
raise QuotaTimeoutError,
|
|
64
|
+
"waited #{timeout}s for rate-limit bucket #{@hash_key || "(unlearned)"} to reset"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
wait = [wait, remaining_time].min
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# A zero wait with no deadline would spin; give the scheduler a tick.
|
|
71
|
+
@condition.wait(@mutex, wait.positive? ? wait : 0.01)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Reconciles local state with the headers on a response.
|
|
77
|
+
#
|
|
78
|
+
# @param limit [Integer, nil]
|
|
79
|
+
# @param remaining [Integer, nil]
|
|
80
|
+
# @param reset_after [Float, nil] seconds until the window resets
|
|
81
|
+
# @return [void]
|
|
82
|
+
def update(limit: nil, remaining: nil, reset_after: nil)
|
|
83
|
+
@mutex.synchronize do
|
|
84
|
+
@limit = Integer(limit) if limit
|
|
85
|
+
@reset_at = @clock.call + Float(reset_after) if reset_after
|
|
86
|
+
|
|
87
|
+
if remaining
|
|
88
|
+
reported = Integer(remaining)
|
|
89
|
+
# Requests we have already let through but whose responses have not
|
|
90
|
+
# come back yet are not reflected in the server's count, so take the
|
|
91
|
+
# pessimistic view.
|
|
92
|
+
@remaining = @remaining == UNKNOWN ? reported : [@remaining, reported].min
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
@condition.broadcast
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Records the bucket hash the server assigned to this route.
|
|
100
|
+
#
|
|
101
|
+
# @param hash_key [String]
|
|
102
|
+
# @return [void]
|
|
103
|
+
def hash_key=(hash_key)
|
|
104
|
+
@mutex.synchronize { @hash_key = hash_key }
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Closes the bucket for +seconds+ after a 429.
|
|
108
|
+
#
|
|
109
|
+
# @param seconds [Numeric]
|
|
110
|
+
# @return [void]
|
|
111
|
+
def penalize(seconds)
|
|
112
|
+
@mutex.synchronize do
|
|
113
|
+
@remaining = 0
|
|
114
|
+
@reset_at = @clock.call + seconds.to_f
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# @return [Hash] a snapshot, for instrumentation and tests
|
|
119
|
+
def state
|
|
120
|
+
@mutex.synchronize do
|
|
121
|
+
{
|
|
122
|
+
hash_key: @hash_key,
|
|
123
|
+
limit: @limit,
|
|
124
|
+
remaining: @remaining,
|
|
125
|
+
reset_in: @reset_at ? [@reset_at - @clock.call, 0.0].max : nil
|
|
126
|
+
}
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
private
|
|
131
|
+
|
|
132
|
+
# Assumes the mutex is held.
|
|
133
|
+
def expire_window
|
|
134
|
+
return unless @reset_at && @clock.call >= @reset_at
|
|
135
|
+
|
|
136
|
+
@remaining = @limit == UNKNOWN ? UNKNOWN : @limit
|
|
137
|
+
@reset_at = nil
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "time"
|
|
5
|
+
require "uri"
|
|
6
|
+
require "securerandom"
|
|
7
|
+
require_relative "http"
|
|
8
|
+
|
|
9
|
+
module DiscordStore
|
|
10
|
+
module Transport
|
|
11
|
+
# An in-memory Discord, good enough to develop and test the whole stack
|
|
12
|
+
# against without a bot token.
|
|
13
|
+
#
|
|
14
|
+
# It models the behaviour that actually shapes this library's design, not
|
|
15
|
+
# just the happy path:
|
|
16
|
+
#
|
|
17
|
+
# * snowflake IDs that really are monotonic and time-encoded
|
|
18
|
+
# * CDN links that carry an +ex+ expiry and stop working when it passes,
|
|
19
|
+
# so any code that caches a URL instead of re-resolving it fails here
|
|
20
|
+
# rather than in production a day after deploy
|
|
21
|
+
# * rate-limit headers, and 429s on demand
|
|
22
|
+
# * a two-week bulk-delete window
|
|
23
|
+
# * messages from other authors, so the own-messages guard is exercised
|
|
24
|
+
#
|
|
25
|
+
# Not a general-purpose Discord mock. It implements exactly the endpoints
|
|
26
|
+
# {REST} calls.
|
|
27
|
+
class Fake
|
|
28
|
+
CDN_HOST = "https://cdn.discordapp.test"
|
|
29
|
+
CDN_TTL = 24 * 60 * 60
|
|
30
|
+
|
|
31
|
+
attr_reader :application_id, :channels, :requests
|
|
32
|
+
|
|
33
|
+
# @param application_id [String] the id the fake attributes our writes to
|
|
34
|
+
# @param clock [#call] returns a Time; move it forward to test expiry
|
|
35
|
+
# @param rate_limit [Hash] the window the fake advertises in its headers.
|
|
36
|
+
# Permissive by default so that tests exercise logic rather than sleep;
|
|
37
|
+
# pass Discord's real per-channel window ({limit: 5, reset_after: 5.0})
|
|
38
|
+
# when the point of the test is the limiter itself.
|
|
39
|
+
def initialize(application_id: "111111111111111111", clock: -> { Time.now },
|
|
40
|
+
rate_limit: { limit: 1000, reset_after: 0.05 })
|
|
41
|
+
@application_id = application_id.to_s
|
|
42
|
+
@clock = clock
|
|
43
|
+
@rate_limit = rate_limit
|
|
44
|
+
@channels = Hash.new { |hash, key| hash[key] = [] }
|
|
45
|
+
@attachments = {}
|
|
46
|
+
@guilds = {}
|
|
47
|
+
@requests = []
|
|
48
|
+
@pending_rate_limits = 0
|
|
49
|
+
@sequence = 0
|
|
50
|
+
@mutex = Mutex.new
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# @param request [Request]
|
|
54
|
+
# @return [Response]
|
|
55
|
+
def call(request)
|
|
56
|
+
@mutex.synchronize do
|
|
57
|
+
@requests << request
|
|
58
|
+
uri = URI.parse(request.url)
|
|
59
|
+
|
|
60
|
+
return cdn_response(uri) if uri.host == URI.parse(CDN_HOST).host
|
|
61
|
+
|
|
62
|
+
if @pending_rate_limits.positive?
|
|
63
|
+
@pending_rate_limits -= 1
|
|
64
|
+
return rate_limited_response
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
dispatch(request, uri)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def close; end
|
|
72
|
+
|
|
73
|
+
# --- Test helpers ------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
# Registers a guild so chunk-size discovery has something to read.
|
|
76
|
+
#
|
|
77
|
+
# @param id [String]
|
|
78
|
+
# @param premium_tier [Integer] 0..3
|
|
79
|
+
# @return [void]
|
|
80
|
+
def seed_guild(id, premium_tier: 0)
|
|
81
|
+
@guilds[id.to_s] = { "id" => id.to_s, "premium_tier" => premium_tier, "name" => "fake" }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Adds a message this library did not write, to prove it gets skipped.
|
|
85
|
+
#
|
|
86
|
+
# @return [Hash] the message
|
|
87
|
+
def seed_foreign_message(channel_id, content:, author_id: "999999999999999999")
|
|
88
|
+
message = build_message(channel_id, content: content, author_id: author_id)
|
|
89
|
+
@channels[channel_id.to_s] << message
|
|
90
|
+
message
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Makes the next +count+ API calls answer 429.
|
|
94
|
+
#
|
|
95
|
+
# @return [void]
|
|
96
|
+
def inject_rate_limits(count: 1)
|
|
97
|
+
@pending_rate_limits = count
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# @param channel_id [String]
|
|
101
|
+
# @return [Array<Hash>]
|
|
102
|
+
def messages_in(channel_id) = @channels[channel_id.to_s]
|
|
103
|
+
|
|
104
|
+
# @return [Integer] total API calls seen, for asserting on request counts
|
|
105
|
+
def request_count = @requests.size
|
|
106
|
+
|
|
107
|
+
# @return [Integer] bytes currently held across every attachment
|
|
108
|
+
def stored_bytes = @attachments.values.sum { |a| a[:content].bytesize }
|
|
109
|
+
|
|
110
|
+
private
|
|
111
|
+
|
|
112
|
+
def dispatch(request, uri)
|
|
113
|
+
method = request.verb.to_s.upcase
|
|
114
|
+
path = uri.path
|
|
115
|
+
query = URI.decode_www_form(uri.query.to_s).to_h
|
|
116
|
+
|
|
117
|
+
case method
|
|
118
|
+
when "POST"
|
|
119
|
+
case path
|
|
120
|
+
when %r{/channels/(\d+)/messages/bulk-delete\z} then bulk_delete(::Regexp.last_match(1), request)
|
|
121
|
+
when %r{/channels/(\d+)/messages\z} then create_message(::Regexp.last_match(1), request)
|
|
122
|
+
else not_found
|
|
123
|
+
end
|
|
124
|
+
when "PATCH"
|
|
125
|
+
if path =~ %r{/channels/(\d+)/messages/(\d+)\z}
|
|
126
|
+
edit_message(::Regexp.last_match(1), ::Regexp.last_match(2), request)
|
|
127
|
+
else
|
|
128
|
+
not_found
|
|
129
|
+
end
|
|
130
|
+
when "DELETE"
|
|
131
|
+
if path =~ %r{/channels/(\d+)/messages/(\d+)\z}
|
|
132
|
+
delete_message(::Regexp.last_match(1), ::Regexp.last_match(2))
|
|
133
|
+
else
|
|
134
|
+
not_found
|
|
135
|
+
end
|
|
136
|
+
when "GET"
|
|
137
|
+
dispatch_get(path, query)
|
|
138
|
+
else
|
|
139
|
+
not_found
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def dispatch_get(path, query)
|
|
144
|
+
case path
|
|
145
|
+
when %r{/channels/(\d+)/messages/(\d+)\z}
|
|
146
|
+
get_message(::Regexp.last_match(1), ::Regexp.last_match(2))
|
|
147
|
+
when %r{/channels/(\d+)/messages\z}
|
|
148
|
+
list_messages(::Regexp.last_match(1), query)
|
|
149
|
+
when %r{/channels/(\d+)\z}
|
|
150
|
+
ok({ "id" => ::Regexp.last_match(1), "type" => 0 })
|
|
151
|
+
when %r{/guilds/(\d+)\z}
|
|
152
|
+
guild = @guilds[::Regexp.last_match(1)]
|
|
153
|
+
guild ? ok(guild) : not_found
|
|
154
|
+
when %r{/users/@me\z}
|
|
155
|
+
ok({ "id" => @application_id, "bot" => true })
|
|
156
|
+
else
|
|
157
|
+
not_found
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# --- Endpoint implementations -----------------------------------------
|
|
162
|
+
|
|
163
|
+
def create_message(channel_id, request)
|
|
164
|
+
payload, files = parse_body(request)
|
|
165
|
+
|
|
166
|
+
nonce = payload["nonce"]
|
|
167
|
+
if nonce
|
|
168
|
+
existing = @channels[channel_id].find { |m| m["nonce"] == nonce }
|
|
169
|
+
# Discord deduplicates on nonce within a short window. Modelling it is
|
|
170
|
+
# what makes retry-after-timeout safe to test.
|
|
171
|
+
return ok(existing) if existing
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
message = build_message(channel_id, content: payload["content"].to_s, author_id: @application_id)
|
|
175
|
+
message["nonce"] = nonce if nonce
|
|
176
|
+
|
|
177
|
+
message["attachments"] = files.map.with_index do |file, index|
|
|
178
|
+
store_attachment(channel_id, message["id"], index, file)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
@channels[channel_id] << message
|
|
182
|
+
ok(message)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def edit_message(channel_id, message_id, request)
|
|
186
|
+
message = find_message(channel_id, message_id)
|
|
187
|
+
return not_found unless message
|
|
188
|
+
|
|
189
|
+
payload, = parse_body(request)
|
|
190
|
+
message["content"] = payload["content"].to_s
|
|
191
|
+
message["edited_timestamp"] = @clock.call.utc.iso8601
|
|
192
|
+
ok(message)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def get_message(channel_id, message_id)
|
|
196
|
+
message = find_message(channel_id, message_id)
|
|
197
|
+
return not_found unless message
|
|
198
|
+
|
|
199
|
+
ok(refresh_attachment_urls(message))
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def list_messages(channel_id, query)
|
|
203
|
+
limit = (query["limit"] || 50).to_i
|
|
204
|
+
messages = @channels[channel_id].sort_by { |m| m["id"].to_i }
|
|
205
|
+
|
|
206
|
+
if (after = query["after"])
|
|
207
|
+
messages = messages.select { |m| m["id"].to_i > after.to_i }
|
|
208
|
+
messages = messages.first(limit)
|
|
209
|
+
elsif (before = query["before"])
|
|
210
|
+
messages = messages.select { |m| m["id"].to_i < before.to_i }
|
|
211
|
+
messages = messages.last(limit)
|
|
212
|
+
else
|
|
213
|
+
messages = messages.last(limit)
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# Discord returns newest first.
|
|
217
|
+
ok(messages.reverse.map { |m| refresh_attachment_urls(m) })
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def delete_message(channel_id, message_id)
|
|
221
|
+
removed = @channels[channel_id].reject! { |m| m["id"] == message_id.to_s }
|
|
222
|
+
return not_found if removed.nil?
|
|
223
|
+
|
|
224
|
+
Response.new(status: 204, headers: rate_limit_headers, body: nil)
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def bulk_delete(channel_id, request)
|
|
228
|
+
payload, = parse_body(request)
|
|
229
|
+
ids = Array(payload["messages"]).map(&:to_s)
|
|
230
|
+
|
|
231
|
+
cutoff = @clock.call - REST::BULK_DELETE_MAX_AGE
|
|
232
|
+
if ids.any? { |id| Snowflake.at(id) <= cutoff }
|
|
233
|
+
return Response.new(
|
|
234
|
+
status: 400,
|
|
235
|
+
headers: rate_limit_headers,
|
|
236
|
+
body: JSON.generate({
|
|
237
|
+
"code" => 50_034,
|
|
238
|
+
"message" => "You can only bulk delete messages that are " \
|
|
239
|
+
"under 14 days old."
|
|
240
|
+
})
|
|
241
|
+
)
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
@channels[channel_id].reject! { |m| ids.include?(m["id"]) }
|
|
245
|
+
Response.new(status: 204, headers: rate_limit_headers, body: nil)
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def cdn_response(uri)
|
|
249
|
+
segments = uri.path.split("/").reject(&:empty?)
|
|
250
|
+
attachment_id = segments[2]
|
|
251
|
+
record = @attachments[attachment_id]
|
|
252
|
+
return not_found unless record
|
|
253
|
+
|
|
254
|
+
query = URI.decode_www_form(uri.query.to_s).to_h
|
|
255
|
+
expires_at = query["ex"].to_s.to_i(16)
|
|
256
|
+
|
|
257
|
+
# The whole point of the fake: an expired link 404s, exactly as Discord's
|
|
258
|
+
# has since it started signing them.
|
|
259
|
+
if expires_at.positive? && @clock.call.to_i > expires_at
|
|
260
|
+
return Response.new(status: 404, headers: {},
|
|
261
|
+
body: "This content is no longer available.")
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
Response.new(status: 200, headers: { "content-type" => "application/octet-stream" },
|
|
265
|
+
body: record[:content])
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# --- Helpers -----------------------------------------------------------
|
|
269
|
+
|
|
270
|
+
def build_message(channel_id, content:, author_id:)
|
|
271
|
+
{
|
|
272
|
+
"id" => next_snowflake,
|
|
273
|
+
"channel_id" => channel_id.to_s,
|
|
274
|
+
"content" => content,
|
|
275
|
+
"author" => { "id" => author_id.to_s, "bot" => author_id.to_s == @application_id },
|
|
276
|
+
"timestamp" => @clock.call.utc.iso8601,
|
|
277
|
+
"attachments" => [],
|
|
278
|
+
"pinned" => false
|
|
279
|
+
}
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def store_attachment(channel_id, _message_id, index, file)
|
|
283
|
+
id = next_snowflake
|
|
284
|
+
@attachments[id] = { content: file[:content].to_s.dup.force_encoding(Encoding::BINARY),
|
|
285
|
+
filename: file[:filename] }
|
|
286
|
+
|
|
287
|
+
{
|
|
288
|
+
"id" => id,
|
|
289
|
+
"filename" => file[:filename],
|
|
290
|
+
"size" => file[:content].to_s.bytesize,
|
|
291
|
+
"url" => signed_url(channel_id, id, file[:filename]),
|
|
292
|
+
"_index" => index
|
|
293
|
+
}
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
# Every read re-signs, exactly as Discord does. Code that stores the URL
|
|
297
|
+
# from a previous read instead of re-fetching the message will pass the
|
|
298
|
+
# first test and fail once the clock moves.
|
|
299
|
+
def refresh_attachment_urls(message)
|
|
300
|
+
copy = message.dup
|
|
301
|
+
copy["attachments"] = message["attachments"].map do |attachment|
|
|
302
|
+
url = signed_url(message["channel_id"], attachment["id"], attachment["filename"])
|
|
303
|
+
attachment.merge("url" => url)
|
|
304
|
+
end
|
|
305
|
+
copy
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
def signed_url(channel_id, attachment_id, filename)
|
|
309
|
+
issued = @clock.call.to_i
|
|
310
|
+
expires = issued + CDN_TTL
|
|
311
|
+
# A real HMAC over Discord's private key; here, any opaque value.
|
|
312
|
+
hmac = SecureRandom.hex(16)
|
|
313
|
+
"#{CDN_HOST}/attachments/#{channel_id}/#{attachment_id}/#{filename}" \
|
|
314
|
+
"?ex=#{expires.to_s(16)}&is=#{issued.to_s(16)}&hm=#{hmac}"
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
def find_message(channel_id, message_id)
|
|
318
|
+
@channels[channel_id.to_s].find { |m| m["id"] == message_id.to_s }
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
def next_snowflake
|
|
322
|
+
@sequence += 1
|
|
323
|
+
(Snowflake.from_time(@clock.call) | (@sequence & Snowflake::INCREMENT_MASK)).to_s
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
def parse_body(request)
|
|
327
|
+
content_type = (request.headers || {}).fetch("Content-Type", "")
|
|
328
|
+
|
|
329
|
+
if content_type.start_with?("multipart/form-data")
|
|
330
|
+
parse_multipart(request.body, content_type)
|
|
331
|
+
else
|
|
332
|
+
[JSON.parse(request.body.to_s), []]
|
|
333
|
+
end
|
|
334
|
+
rescue JSON::ParserError
|
|
335
|
+
[{}, []]
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
def parse_multipart(body, content_type)
|
|
339
|
+
boundary = content_type[/boundary=(.+)\z/, 1]
|
|
340
|
+
return [{}, []] unless boundary
|
|
341
|
+
|
|
342
|
+
payload = {}
|
|
343
|
+
files = []
|
|
344
|
+
|
|
345
|
+
body.to_s.split("--#{boundary}").each do |part|
|
|
346
|
+
headers, content = part.split("\r\n\r\n", 2)
|
|
347
|
+
next unless headers && content
|
|
348
|
+
|
|
349
|
+
content = content.sub(/\r\n\z/, "")
|
|
350
|
+
|
|
351
|
+
if headers.include?('name="payload_json"')
|
|
352
|
+
payload = JSON.parse(content)
|
|
353
|
+
elsif (filename = headers[/filename="([^"]*)"/, 1])
|
|
354
|
+
files << { filename: filename, content: content }
|
|
355
|
+
end
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
[payload, files]
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
def ok(payload)
|
|
362
|
+
Response.new(status: 200, headers: rate_limit_headers, body: JSON.generate(payload))
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
def not_found
|
|
366
|
+
Response.new(status: 404, headers: rate_limit_headers,
|
|
367
|
+
body: JSON.generate({ "code" => 10_008, "message" => "Unknown Message" }))
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
def rate_limited_response
|
|
371
|
+
Response.new(
|
|
372
|
+
status: 429,
|
|
373
|
+
headers: rate_limit_headers.merge(
|
|
374
|
+
"x-ratelimit-remaining" => "0",
|
|
375
|
+
"x-ratelimit-reset-after" => "0.01",
|
|
376
|
+
"retry-after" => "0.01",
|
|
377
|
+
"x-ratelimit-scope" => "user"
|
|
378
|
+
),
|
|
379
|
+
body: JSON.generate({ "message" => "You are being rate limited.", "retry_after" => 0.01 })
|
|
380
|
+
)
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
def rate_limit_headers
|
|
384
|
+
limit = @rate_limit[:limit]
|
|
385
|
+
{
|
|
386
|
+
"x-ratelimit-bucket" => "fake-bucket",
|
|
387
|
+
"x-ratelimit-limit" => limit.to_s,
|
|
388
|
+
"x-ratelimit-remaining" => (limit - 1).to_s,
|
|
389
|
+
"x-ratelimit-reset-after" => @rate_limit[:reset_after].to_s
|
|
390
|
+
}
|
|
391
|
+
end
|
|
392
|
+
end
|
|
393
|
+
end
|
|
394
|
+
end
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "uri"
|
|
5
|
+
require "securerandom"
|
|
6
|
+
|
|
7
|
+
module DiscordStore
|
|
8
|
+
module Transport
|
|
9
|
+
# What every HTTP backend returns.
|
|
10
|
+
Response = Struct.new(:status, :headers, :body, keyword_init: true) do
|
|
11
|
+
def success? = status.between?(200, 299)
|
|
12
|
+
def rate_limited? = status == 429
|
|
13
|
+
def server_error? = status >= 500
|
|
14
|
+
|
|
15
|
+
def json
|
|
16
|
+
return nil if body.nil? || body.empty?
|
|
17
|
+
|
|
18
|
+
require "json"
|
|
19
|
+
JSON.parse(body)
|
|
20
|
+
rescue JSON::ParserError
|
|
21
|
+
nil
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# A request that has not been sent yet. Kept as a value object so the fake
|
|
26
|
+
# backend can assert against exactly what the real one would have sent.
|
|
27
|
+
#
|
|
28
|
+
# The HTTP verb is +verb+, not +method+: a Struct member called method would
|
|
29
|
+
# shadow Object#method, and losing that on a value object passed through
|
|
30
|
+
# several layers is not worth the nicer name.
|
|
31
|
+
Request = Struct.new(:verb, :url, :headers, :body, keyword_init: true)
|
|
32
|
+
|
|
33
|
+
# Net::HTTP backend.
|
|
34
|
+
#
|
|
35
|
+
# Connections are cached per (host, port) in fiber-local storage rather than
|
|
36
|
+
# thread-local. Under a fiber scheduler two fibers on one thread run
|
|
37
|
+
# concurrently, and sharing one Net::HTTP object between them interleaves
|
|
38
|
+
# bytes on the socket and corrupts both responses — the same failure that
|
|
39
|
+
# forced Rails to grow a fiber-aware connection pool, arriving here through
|
|
40
|
+
# a completely different door.
|
|
41
|
+
class NetHTTP
|
|
42
|
+
# @param open_timeout [Numeric]
|
|
43
|
+
# @param read_timeout [Numeric]
|
|
44
|
+
# @param write_timeout [Numeric]
|
|
45
|
+
def initialize(open_timeout: 5.0, read_timeout: 30.0, write_timeout: 30.0)
|
|
46
|
+
@open_timeout = open_timeout
|
|
47
|
+
@read_timeout = read_timeout
|
|
48
|
+
@write_timeout = write_timeout
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# @param request [Request]
|
|
52
|
+
# @return [Response]
|
|
53
|
+
def call(request)
|
|
54
|
+
uri = URI.parse(request.url)
|
|
55
|
+
http = connection_for(uri)
|
|
56
|
+
|
|
57
|
+
net_request = build_request(request, uri)
|
|
58
|
+
response = http.request(net_request)
|
|
59
|
+
|
|
60
|
+
Response.new(
|
|
61
|
+
status: response.code.to_i,
|
|
62
|
+
headers: flatten_headers(response),
|
|
63
|
+
body: response.body
|
|
64
|
+
)
|
|
65
|
+
rescue Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout => e
|
|
66
|
+
# A dead route that silently drops packets is indistinguishable from a
|
|
67
|
+
# slow one, so every phase is bounded. Surfaced as a retryable 599.
|
|
68
|
+
Response.new(status: 599, headers: {}, body: "#{e.class}: #{e.message}")
|
|
69
|
+
rescue SystemCallError, OpenSSL::SSL::SSLError, IOError => e
|
|
70
|
+
drop_connection(uri)
|
|
71
|
+
Response.new(status: 599, headers: {}, body: "#{e.class}: #{e.message}")
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Closes every cached connection held by the current fiber.
|
|
75
|
+
#
|
|
76
|
+
# @return [void]
|
|
77
|
+
def close
|
|
78
|
+
connections.each_value { |http| http.finish if http.started? }
|
|
79
|
+
connections.clear
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
def build_request(request, uri)
|
|
85
|
+
klass = case request.verb.to_s.upcase
|
|
86
|
+
when "GET" then Net::HTTP::Get
|
|
87
|
+
when "POST" then Net::HTTP::Post
|
|
88
|
+
when "PATCH" then Net::HTTP::Patch
|
|
89
|
+
when "PUT" then Net::HTTP::Put
|
|
90
|
+
when "DELETE" then Net::HTTP::Delete
|
|
91
|
+
else raise ArgumentError, "unsupported method #{request.verb}"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
net_request = klass.new(uri.request_uri)
|
|
95
|
+
(request.headers || {}).each { |key, value| net_request[key] = value }
|
|
96
|
+
net_request.body = request.body if request.body
|
|
97
|
+
net_request
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def connection_for(uri)
|
|
101
|
+
key = "#{uri.scheme}://#{uri.host}:#{uri.port}"
|
|
102
|
+
|
|
103
|
+
cached = connections[key]
|
|
104
|
+
return cached if cached&.started?
|
|
105
|
+
|
|
106
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
107
|
+
http.use_ssl = uri.scheme == "https"
|
|
108
|
+
http.open_timeout = @open_timeout
|
|
109
|
+
http.read_timeout = @read_timeout
|
|
110
|
+
http.write_timeout = @write_timeout
|
|
111
|
+
http.keep_alive_timeout = 30
|
|
112
|
+
http.start
|
|
113
|
+
|
|
114
|
+
connections[key] = http
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def drop_connection(uri)
|
|
118
|
+
key = "#{uri.scheme}://#{uri.host}:#{uri.port}"
|
|
119
|
+
http = connections.delete(key)
|
|
120
|
+
http.finish if http&.started?
|
|
121
|
+
rescue IOError
|
|
122
|
+
# Already closed; nothing to do.
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Fiber-local, and therefore also thread-local: every thread has a root
|
|
126
|
+
# fiber, so this is strictly narrower than Thread.current storage.
|
|
127
|
+
def connections
|
|
128
|
+
Fiber[:discord_store_connections] ||= {}
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def flatten_headers(response)
|
|
132
|
+
response.each_header.to_h { |key, value| [key.downcase, value] }
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Builds a multipart/form-data body the way Discord's attachment endpoints
|
|
137
|
+
# expect it: a +payload_json+ part carrying the message, plus one part per
|
|
138
|
+
# file named files[n].
|
|
139
|
+
module Multipart
|
|
140
|
+
module_function
|
|
141
|
+
|
|
142
|
+
# @param payload [String] the JSON message payload
|
|
143
|
+
# @param files [Array<Hash>] each {filename:, content:, content_type:}
|
|
144
|
+
# @return [Array(String, String)] content type and body
|
|
145
|
+
def encode(payload, files)
|
|
146
|
+
boundary = "----discordstore#{SecureRandom.hex(16)}"
|
|
147
|
+
body = +""
|
|
148
|
+
|
|
149
|
+
body << "--#{boundary}\r\n"
|
|
150
|
+
body << "Content-Disposition: form-data; name=\"payload_json\"\r\n"
|
|
151
|
+
body << "Content-Type: application/json\r\n\r\n"
|
|
152
|
+
body << payload
|
|
153
|
+
body << "\r\n"
|
|
154
|
+
|
|
155
|
+
files.each_with_index do |file, index|
|
|
156
|
+
body << "--#{boundary}\r\n"
|
|
157
|
+
body << "Content-Disposition: form-data; name=\"files[#{index}]\"; " \
|
|
158
|
+
"filename=\"#{sanitize(file[:filename])}\"\r\n"
|
|
159
|
+
body << "Content-Type: #{file[:content_type] || "application/octet-stream"}\r\n\r\n"
|
|
160
|
+
body << file[:content].to_s.dup.force_encoding(Encoding::BINARY)
|
|
161
|
+
body << "\r\n"
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
body << "--#{boundary}--\r\n"
|
|
165
|
+
|
|
166
|
+
["multipart/form-data; boundary=#{boundary}", body]
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def sanitize(filename)
|
|
170
|
+
filename.to_s.gsub(/["\r\n\\]/, "_")
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|