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,271 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "discord_store"
|
|
4
|
+
require "active_record/connection_adapters/sqlite3_adapter"
|
|
5
|
+
require "discord_store/journal"
|
|
6
|
+
require "discord_store/replay"
|
|
7
|
+
|
|
8
|
+
module ActiveRecord
|
|
9
|
+
module ConnectionAdapters
|
|
10
|
+
# An ActiveRecord adapter whose durable store is a Discord channel.
|
|
11
|
+
#
|
|
12
|
+
# # config/database.yml
|
|
13
|
+
# development:
|
|
14
|
+
# adapter: discord
|
|
15
|
+
# database: storage/development.sqlite3
|
|
16
|
+
# discord:
|
|
17
|
+
# i_understand_this_violates_discord_tos: true
|
|
18
|
+
# token: <%= ENV["DISCORD_BOT_TOKEN"] %>
|
|
19
|
+
# application_id: <%= ENV["DISCORD_APPLICATION_ID"] %>
|
|
20
|
+
# secret_key: <%= ENV["DISCORD_STORE_KEY"] %>
|
|
21
|
+
# log_channel_ids: ["1234567890"]
|
|
22
|
+
#
|
|
23
|
+
# == What this actually is
|
|
24
|
+
#
|
|
25
|
+
# It is a SQLite adapter that mirrors every write into a Discord channel,
|
|
26
|
+
# and it is cheating. The README says so too. Here is why it cheats.
|
|
27
|
+
#
|
|
28
|
+
# Discord gives a bot no query interface whatsoever. There is no WHERE, no
|
|
29
|
+
# index, no server-side filter; there is a channel you may page through a
|
|
30
|
+
# hundred messages at a time. Answering +User.where(email: ...)+ against
|
|
31
|
+
# that means scanning the channel, which is why the honest implementations
|
|
32
|
+
# of this idea top out at a few megabytes per second and cannot do a join at
|
|
33
|
+
# all.
|
|
34
|
+
#
|
|
35
|
+
# So Discord is not asked to be a query engine. It is asked to be the
|
|
36
|
+
# durable, replicated, ordered write-ahead log — a thing it is unexpectedly
|
|
37
|
+
# decent at, because message IDs are snowflakes and therefore already a
|
|
38
|
+
# monotonic sequence with a timestamp in them — and a local SQLite file is
|
|
39
|
+
# the materialized view you actually query. Writes go to the log first, then
|
|
40
|
+
# to SQLite. Reads never touch the network. +rake discord:replay+ rebuilds
|
|
41
|
+
# the SQLite file from the channel, from empty, on any machine.
|
|
42
|
+
#
|
|
43
|
+
# That is a materialized view over a replicated log, which is a normal thing
|
|
44
|
+
# that normal systems do. The unusual part is only where the log lives.
|
|
45
|
+
#
|
|
46
|
+
# == What it costs
|
|
47
|
+
#
|
|
48
|
+
# A transaction is one message, and a channel accepts about five messages
|
|
49
|
+
# every five seconds. So this adapter sustains roughly *one write
|
|
50
|
+
# transaction per second*. That is not a tuning problem, it is the platform,
|
|
51
|
+
# and it is why +journal_mode: :async+ exists (batching many transactions
|
|
52
|
+
# into one message, at the cost of a durability window) and why writes are
|
|
53
|
+
# not sharded across channels by default (a write-ahead log with no total
|
|
54
|
+
# order is not a write-ahead log).
|
|
55
|
+
#
|
|
56
|
+
# Reads are as fast as SQLite, which is to say, fast.
|
|
57
|
+
class DiscordAdapter < SQLite3Adapter
|
|
58
|
+
ADAPTER_NAME = "Discord"
|
|
59
|
+
|
|
60
|
+
# Statements that mutate. Everything else is served locally and never
|
|
61
|
+
# reaches the network.
|
|
62
|
+
WRITE_STATEMENT = /\A\s*(?:INSERT|UPDATE|DELETE|REPLACE|CREATE|ALTER|DROP|TRUNCATE)\b/i
|
|
63
|
+
|
|
64
|
+
# Statements whose result depends on when they run, and which therefore
|
|
65
|
+
# cannot be replayed faithfully.
|
|
66
|
+
NON_DETERMINISTIC = /\b(?:RANDOM|CURRENT_TIMESTAMP|CURRENT_DATE|CURRENT_TIME)\s*(?:\(\s*\))?/i
|
|
67
|
+
|
|
68
|
+
class << self
|
|
69
|
+
# Rails calls this to build the underlying SQLite connection; the Discord
|
|
70
|
+
# half is set up in #initialize, after super.
|
|
71
|
+
def new_client(config)
|
|
72
|
+
super(config.except(:discord, :journal_mode, :replay_on_connect))
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# @return [DiscordStore::Journal]
|
|
77
|
+
attr_reader :journal
|
|
78
|
+
|
|
79
|
+
def initialize(...)
|
|
80
|
+
super
|
|
81
|
+
@discord_config = extract_discord_config
|
|
82
|
+
@journal = build_journal
|
|
83
|
+
@statement_buffer = nil
|
|
84
|
+
replay_on_connect! if @discord_config[:replay_on_connect]
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# --- Write interception -------------------------------------------------
|
|
88
|
+
|
|
89
|
+
def exec_insert(sql, name = nil, binds = [], pk = nil, sequence_name = nil, returning: nil)
|
|
90
|
+
record_statement(sql, binds)
|
|
91
|
+
super
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def exec_update(sql, name = nil, binds = [])
|
|
95
|
+
record_statement(sql, binds)
|
|
96
|
+
super
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def exec_delete(sql, name = nil, binds = [])
|
|
100
|
+
record_statement(sql, binds)
|
|
101
|
+
super
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Catches DDL, which migrations issue through #execute rather than through
|
|
105
|
+
# the exec_* trio. A migration is a write to the log like any other, which
|
|
106
|
+
# is what makes a replay onto an empty file reproduce the schema as well as
|
|
107
|
+
# the rows.
|
|
108
|
+
def execute(sql, name = nil, **kwargs)
|
|
109
|
+
record_statement(sql, []) if sql.is_a?(String) && WRITE_STATEMENT.match?(sql)
|
|
110
|
+
super
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# --- Transactions -------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
# Note the absence of a begin_db_transaction override.
|
|
116
|
+
#
|
|
117
|
+
# Rails materialises transactions lazily: the first write inside a
|
|
118
|
+
# transaction reaches #exec_insert before BEGIN is ever sent. An override
|
|
119
|
+
# here that initialised the statement buffer would therefore throw that
|
|
120
|
+
# first statement away, which is why #record_statement asks the
|
|
121
|
+
# transaction manager whether a transaction is open instead of trusting a
|
|
122
|
+
# callback that has not fired yet.
|
|
123
|
+
#
|
|
124
|
+
# The log is written before SQLite commits, not after.
|
|
125
|
+
#
|
|
126
|
+
# There is no two-phase commit available between a SQLite file and a chat
|
|
127
|
+
# server, so one of them has to go first and the other has to be the one
|
|
128
|
+
# that can be reconstructed. Writing the log first means a crash in between
|
|
129
|
+
# leaves a record that replay will apply, and the local file catches up.
|
|
130
|
+
# Committing first would leave rows that exist nowhere else, which in a
|
|
131
|
+
# design where the log is the source of truth is simply data loss.
|
|
132
|
+
def commit_db_transaction
|
|
133
|
+
flush_buffer!
|
|
134
|
+
super
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def exec_rollback_db_transaction
|
|
138
|
+
@statement_buffer = nil
|
|
139
|
+
super
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# --- Log operations -----------------------------------------------------
|
|
143
|
+
|
|
144
|
+
# Rebuilds this connection's SQLite database from the Discord log.
|
|
145
|
+
#
|
|
146
|
+
# @param from [String, nil] cursor to resume from; nil replays everything
|
|
147
|
+
# @return [Hash] {applied:, skipped:, cursor:}
|
|
148
|
+
def replay!(from: nil)
|
|
149
|
+
DiscordStore::Replay.new(connection: self, journal: @journal).call(from: from)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# @return [String, nil] cursor of the newest record in the log
|
|
153
|
+
def log_tip = @journal.tip
|
|
154
|
+
|
|
155
|
+
# Everything the local file has that the log does not, and vice versa.
|
|
156
|
+
#
|
|
157
|
+
# @return [Hash]
|
|
158
|
+
def log_status
|
|
159
|
+
{ local_cursor: @journal.local_cursor, remote_cursor: @journal.tip,
|
|
160
|
+
pending: @journal.pending_count, mode: @journal.mode }
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Flushes anything buffered by +journal_mode: :async+.
|
|
164
|
+
#
|
|
165
|
+
# @return [Integer] records written
|
|
166
|
+
def flush_journal! = @journal.flush!
|
|
167
|
+
|
|
168
|
+
def supports_savepoints? = true
|
|
169
|
+
|
|
170
|
+
# Discord cannot participate in a savepoint: a message, once sent, is sent.
|
|
171
|
+
# Statements inside a savepoint that later rolls back would still be in the
|
|
172
|
+
# log, so replay would apply work the database rolled back.
|
|
173
|
+
def create_savepoint(name = current_savepoint_name)
|
|
174
|
+
if @journal.recording?
|
|
175
|
+
raise DiscordStore::Error,
|
|
176
|
+
"savepoints cannot be journalled: a sent message cannot be un-sent, so a " \
|
|
177
|
+
"rolled-back savepoint would still replay. Set journal_mode: :off for this " \
|
|
178
|
+
"connection, or avoid nested transactions with requires_new: true."
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
super
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def disconnect!
|
|
185
|
+
@journal.flush! if @journal&.recording?
|
|
186
|
+
super
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
private
|
|
190
|
+
|
|
191
|
+
def record_statement(sql, binds)
|
|
192
|
+
return unless @journal.recording?
|
|
193
|
+
return unless sql.is_a?(String)
|
|
194
|
+
|
|
195
|
+
# Replay's own bookkeeping table is local state about the log. Writing it
|
|
196
|
+
# into the log would make every replay append the record of itself.
|
|
197
|
+
return if sql.include?(DiscordStore::Journal::CURSOR_TABLE)
|
|
198
|
+
|
|
199
|
+
warn_non_deterministic(sql)
|
|
200
|
+
|
|
201
|
+
entry = { "sql" => sql, "binds" => DiscordStore::Journal.serialize_binds(binds) }
|
|
202
|
+
|
|
203
|
+
if in_open_transaction?
|
|
204
|
+
(@statement_buffer ||= []) << entry
|
|
205
|
+
else
|
|
206
|
+
# An autocommit write is a transaction of one.
|
|
207
|
+
@journal.write([entry])
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# True from the moment Rails opens a transaction object, which is before it
|
|
212
|
+
# bothers to send BEGIN.
|
|
213
|
+
def in_open_transaction?
|
|
214
|
+
current = transaction_manager.current_transaction
|
|
215
|
+
current.respond_to?(:open?) && current.open?
|
|
216
|
+
rescue StandardError
|
|
217
|
+
false
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def flush_buffer!
|
|
221
|
+
buffered = @statement_buffer
|
|
222
|
+
@statement_buffer = nil
|
|
223
|
+
return if buffered.nil? || buffered.empty?
|
|
224
|
+
|
|
225
|
+
@journal.write(buffered)
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# Rails sends timestamps as bind parameters, so this fires rarely; when it
|
|
229
|
+
# does, the statement will replay to a different value than it produced,
|
|
230
|
+
# and silence would be the wrong response.
|
|
231
|
+
def warn_non_deterministic(sql)
|
|
232
|
+
return unless NON_DETERMINISTIC.match?(sql)
|
|
233
|
+
|
|
234
|
+
message = "discord_store: journalled a non-deterministic statement; a replay will " \
|
|
235
|
+
"not reproduce this row exactly: #{sql[0, 200]}"
|
|
236
|
+
(@discord_config[:logger] || ActiveRecord::Base.logger)&.warn(message)
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def extract_discord_config
|
|
240
|
+
raw = @config[:discord] || @config["discord"] || {}
|
|
241
|
+
raw.to_h.transform_keys(&:to_sym).tap do |options|
|
|
242
|
+
options[:journal_mode] = (options[:journal_mode] || @config[:journal_mode] || :sync).to_sym
|
|
243
|
+
options[:replay_on_connect] = @config[:replay_on_connect] || options[:replay_on_connect]
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def build_journal
|
|
248
|
+
DiscordStore::Journal.new(
|
|
249
|
+
config: DiscordStore::Journal.build_configuration(@discord_config),
|
|
250
|
+
mode: @discord_config[:journal_mode],
|
|
251
|
+
http: @discord_config[:http] # injectable for tests
|
|
252
|
+
)
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def replay_on_connect!
|
|
256
|
+
replay!
|
|
257
|
+
rescue DiscordStore::Error => e
|
|
258
|
+
(@discord_config[:logger] || ActiveRecord::Base.logger)&.error(
|
|
259
|
+
"discord_store: replay on connect failed: #{e.message}"
|
|
260
|
+
)
|
|
261
|
+
raise
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
ActiveRecord::ConnectionAdapters.register(
|
|
268
|
+
"discord",
|
|
269
|
+
"ActiveRecord::ConnectionAdapters::DiscordAdapter",
|
|
270
|
+
"active_record/connection_adapters/discord_adapter"
|
|
271
|
+
)
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "discord_store"
|
|
4
|
+
require "tempfile"
|
|
5
|
+
|
|
6
|
+
module ActiveStorage
|
|
7
|
+
class Service
|
|
8
|
+
# An ActiveStorage service backed by Discord message attachments.
|
|
9
|
+
#
|
|
10
|
+
# # config/storage.yml
|
|
11
|
+
# discord:
|
|
12
|
+
# service: Discord
|
|
13
|
+
# token: <%= ENV["DISCORD_BOT_TOKEN"] %>
|
|
14
|
+
# application_id: <%= ENV["DISCORD_APPLICATION_ID"] %>
|
|
15
|
+
# guild_id: <%= ENV["DISCORD_GUILD_ID"] %>
|
|
16
|
+
# secret_key: <%= ENV["DISCORD_STORE_KEY"] %>
|
|
17
|
+
# blob_channel_ids: ["...", "..."]
|
|
18
|
+
# manifest_channel_id: "..."
|
|
19
|
+
# i_understand_this_violates_discord_tos: true
|
|
20
|
+
#
|
|
21
|
+
# Configure the service to be proxied, not redirected:
|
|
22
|
+
#
|
|
23
|
+
# # config/environments/production.rb
|
|
24
|
+
# config.active_storage.resolve_model_to_route = :rails_storage_proxy
|
|
25
|
+
#
|
|
26
|
+
# That line is the whole point of building this on Rails rather than from
|
|
27
|
+
# scratch.
|
|
28
|
+
#
|
|
29
|
+
# When Discord began signing CDN links with an expiring HMAC at the end of
|
|
30
|
+
# 2023, every Discord-backed filesystem that had stored URLs broke about a
|
|
31
|
+
# day later, and the community's fix was to deploy caching proxies on
|
|
32
|
+
# Cloudflare Workers that re-fetch the message and hand back a fresh link.
|
|
33
|
+
# ActiveStorage has shipped that proxy for years — it is
|
|
34
|
+
# ActiveStorage::Blobs::ProxyController — and in proxy mode reads go through
|
|
35
|
+
# it, so the URL a browser sees is a Rails URL that never expires and the
|
|
36
|
+
# Discord link is re-resolved per request, server-side, where it belongs.
|
|
37
|
+
#
|
|
38
|
+
# Redirect mode works only for blobs small enough to be a single attachment,
|
|
39
|
+
# because a blob split across attachments has no single URL to redirect to.
|
|
40
|
+
class DiscordService < Service
|
|
41
|
+
# What every other ActiveStorage service yields per iteration when
|
|
42
|
+
# streaming a download. Not a Discord number -- a Rails one.
|
|
43
|
+
STREAM_CHUNK_SIZE = 5 * 1024 * 1024
|
|
44
|
+
|
|
45
|
+
attr_reader :client, :blobs
|
|
46
|
+
|
|
47
|
+
# @param config [Hash] the storage.yml stanza, symbolized
|
|
48
|
+
def initialize(**config)
|
|
49
|
+
@config = config
|
|
50
|
+
# +http+ is a test seam, the same one S3Service exposes as +client+:
|
|
51
|
+
# it lets the conformance suite run the whole service against an
|
|
52
|
+
# in-memory Discord. Nothing in storage.yml sets it.
|
|
53
|
+
@client = DiscordStore::Client.new(
|
|
54
|
+
config: build_configuration(config), http: config[:http]
|
|
55
|
+
)
|
|
56
|
+
@blobs = @client.blobs
|
|
57
|
+
super()
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# @return [void]
|
|
61
|
+
def upload(key, io, checksum: nil, content_type: nil, **)
|
|
62
|
+
instrument :upload, key: key, checksum: checksum do
|
|
63
|
+
@blobs.put(key, io, content_type: content_type || "application/octet-stream",
|
|
64
|
+
checksum: checksum)
|
|
65
|
+
end
|
|
66
|
+
rescue DiscordStore::Error => e
|
|
67
|
+
raise ActiveStorage::IntegrityError, e.message if e.message.include?("checksum")
|
|
68
|
+
|
|
69
|
+
raise
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# @return [String, void]
|
|
73
|
+
def download(key, &block)
|
|
74
|
+
if block
|
|
75
|
+
instrument :streaming_download, key: key do
|
|
76
|
+
stream(key, &block)
|
|
77
|
+
end
|
|
78
|
+
else
|
|
79
|
+
instrument :download, key: key do
|
|
80
|
+
@blobs.get(key)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
rescue DiscordStore::NotFoundError
|
|
84
|
+
raise ActiveStorage::FileNotFoundError
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Concatenates several blobs into one.
|
|
88
|
+
#
|
|
89
|
+
# S3 and GCS compose server-side; Discord cannot, so this reads the
|
|
90
|
+
# sources and writes a new blob. It goes through a Tempfile rather than a
|
|
91
|
+
# String because the inputs are attachments and composing four of them at
|
|
92
|
+
# a 100 MiB tier would otherwise put 400 MiB on the heap.
|
|
93
|
+
#
|
|
94
|
+
# +filename+ and +disposition+ are accepted and dropped: Discord stores no
|
|
95
|
+
# per-object metadata, and this service serves downloads through the
|
|
96
|
+
# application anyway, which is where those two get applied.
|
|
97
|
+
#
|
|
98
|
+
# @return [void]
|
|
99
|
+
def compose(source_keys, destination_key, content_type: nil, **)
|
|
100
|
+
instrument :compose, key: destination_key, source_keys: source_keys do
|
|
101
|
+
Tempfile.create(["discord-store-compose", ".bin"]) do |scratch|
|
|
102
|
+
scratch.binmode
|
|
103
|
+
source_keys.each { |source_key| @blobs.download(source_key) { |bytes| scratch.write(bytes) } }
|
|
104
|
+
scratch.rewind
|
|
105
|
+
@blobs.put(destination_key, scratch,
|
|
106
|
+
content_type: content_type || "application/octet-stream")
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# @param key [String]
|
|
112
|
+
# @param range [Range]
|
|
113
|
+
# @return [String]
|
|
114
|
+
def download_chunk(key, range)
|
|
115
|
+
instrument :download_chunk, key: key, range: range do
|
|
116
|
+
@blobs.get_range(key, range)
|
|
117
|
+
end
|
|
118
|
+
rescue DiscordStore::NotFoundError
|
|
119
|
+
raise ActiveStorage::FileNotFoundError
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# @return [void]
|
|
123
|
+
def delete(key)
|
|
124
|
+
instrument :delete, key: key do
|
|
125
|
+
@blobs.delete(key)
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# @return [void]
|
|
130
|
+
def delete_prefixed(prefix)
|
|
131
|
+
instrument :delete_prefixed, prefix: prefix do
|
|
132
|
+
@blobs.delete_prefix(prefix)
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# @return [Boolean]
|
|
137
|
+
def exist?(key)
|
|
138
|
+
instrument :exist, key: key do |payload|
|
|
139
|
+
payload[:exist] = @blobs.exist?(key)
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Discord has no pre-signed upload endpoint, so a browser cannot PUT
|
|
144
|
+
# straight to it. Every byte goes through the application.
|
|
145
|
+
def url_for_direct_upload(*, **)
|
|
146
|
+
raise NotImplementedError,
|
|
147
|
+
"Discord has no direct-upload endpoint. Uploads must pass through your " \
|
|
148
|
+
"application, which also means they are bounded by your dyno's bandwidth " \
|
|
149
|
+
"and by a rate limit measured in single-digit megabytes per second."
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def headers_for_direct_upload(*, **) = {}
|
|
153
|
+
|
|
154
|
+
private
|
|
155
|
+
|
|
156
|
+
# Redirect mode: hand back a freshly signed CDN link.
|
|
157
|
+
#
|
|
158
|
+
# Only viable for a single-chunk blob. Anything larger has no single URL,
|
|
159
|
+
# and there is no honest way to invent one — which is why proxy mode is
|
|
160
|
+
# the documented configuration.
|
|
161
|
+
def private_url(key, expires_in: nil, filename: nil, content_type: nil, disposition: nil, **)
|
|
162
|
+
@blobs.url(key)
|
|
163
|
+
rescue DiscordStore::Error => e
|
|
164
|
+
raise ActiveStorage::FileNotFoundError if e.is_a?(DiscordStore::NotFoundError)
|
|
165
|
+
|
|
166
|
+
raise e.class, <<~MSG
|
|
167
|
+
#{e.message}
|
|
168
|
+
|
|
169
|
+
Set config.active_storage.resolve_model_to_route = :rails_storage_proxy so reads
|
|
170
|
+
go through ActiveStorage::Blobs::ProxyController, which reassembles the chunks
|
|
171
|
+
server-side and never hands an expiring Discord link to a browser.
|
|
172
|
+
MSG
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Discord links are already time-limited by Discord, on Discord's schedule,
|
|
176
|
+
# and nothing here can lengthen or shorten that.
|
|
177
|
+
def public_url(key, **)
|
|
178
|
+
raise NotImplementedError,
|
|
179
|
+
"a Discord-backed service cannot be public: every CDN link Discord issues " \
|
|
180
|
+
"expires on its own schedule, so there is no stable public URL to publish."
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# ActiveStorage's services all hand back 5 MB slices when streaming, and
|
|
184
|
+
# Rails' conformance suite asserts it exactly. Discord's chunks are sized
|
|
185
|
+
# by the guild's attachment ceiling instead -- 8 MiB to 100 MiB depending
|
|
186
|
+
# on boost tier -- which is storage geometry and no business of a caller
|
|
187
|
+
# iterating a download. Re-slice on the way out.
|
|
188
|
+
def stream(key)
|
|
189
|
+
buffer = (+"").force_encoding(Encoding::BINARY)
|
|
190
|
+
|
|
191
|
+
@blobs.download(key) do |bytes|
|
|
192
|
+
buffer << bytes
|
|
193
|
+
yield buffer.slice!(0, STREAM_CHUNK_SIZE) while buffer.bytesize >= STREAM_CHUNK_SIZE
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
yield buffer unless buffer.empty?
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def build_configuration(options)
|
|
200
|
+
DiscordStore::Configuration.new.tap do |config|
|
|
201
|
+
apply_credentials(config, options)
|
|
202
|
+
apply_placement(config, options)
|
|
203
|
+
apply_behaviour(config, options)
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def apply_credentials(config, options)
|
|
208
|
+
config.i_understand_this_violates_discord_tos =
|
|
209
|
+
options[:i_understand_this_violates_discord_tos]
|
|
210
|
+
config.token = options[:token]
|
|
211
|
+
config.application_id = options[:application_id]
|
|
212
|
+
config.guild_id = options[:guild_id]
|
|
213
|
+
config.secret_key = options[:secret_key]
|
|
214
|
+
config.cipher = options[:cipher]&.to_sym || :aes_256_gcm
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def apply_placement(config, options)
|
|
218
|
+
config.blob_channel_ids = Array(options[:blob_channel_ids]).map(&:to_s)
|
|
219
|
+
config.manifest_channel_id = options[:manifest_channel_id]&.to_s
|
|
220
|
+
config.log_channel_ids = Array(options[:log_channel_ids]).map(&:to_s)
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def apply_behaviour(config, options)
|
|
224
|
+
config.chunk_size = options[:chunk_size]
|
|
225
|
+
config.delete_policy = (options[:delete_policy] || :tombstone).to_sym
|
|
226
|
+
config.max_concurrent_transfers =
|
|
227
|
+
options[:max_concurrent_transfers] || config.max_concurrent_transfers
|
|
228
|
+
config.logger = options[:logger]
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
end
|