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,195 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "openssl"
|
|
4
|
+
require "base64"
|
|
5
|
+
require "securerandom"
|
|
6
|
+
|
|
7
|
+
module DiscordStore
|
|
8
|
+
# Payload encryption.
|
|
9
|
+
#
|
|
10
|
+
# Discord's own Developer Terms require that end-user data stored off-platform
|
|
11
|
+
# be encrypted at rest, so a library that stores data *on* the platform has no
|
|
12
|
+
# excuse for shipping plaintext as the default. Encryption is also the only
|
|
13
|
+
# thing standing between your records and every member of the guild who can
|
|
14
|
+
# scroll up.
|
|
15
|
+
#
|
|
16
|
+
# AES-256-GCM, not the AES-256-CBC that earlier Discord filesystems used: GCM
|
|
17
|
+
# authenticates, so a member with Manage Messages who edits one of our messages
|
|
18
|
+
# produces a decryption failure rather than silently corrupted data.
|
|
19
|
+
module Cipher
|
|
20
|
+
# Envelope prefix, so the format can change without ambiguity later.
|
|
21
|
+
VERSION_TAG = "ds1"
|
|
22
|
+
SEPARATOR = "."
|
|
23
|
+
IV_BYTES = 12
|
|
24
|
+
TAG_BYTES = 16
|
|
25
|
+
ALGORITHM = "aes-256-gcm"
|
|
26
|
+
|
|
27
|
+
# Builds the cipher named by +config+.
|
|
28
|
+
#
|
|
29
|
+
# @param config [DiscordStore::Configuration]
|
|
30
|
+
# @return [#seal, #open]
|
|
31
|
+
def self.build(config)
|
|
32
|
+
case config.cipher
|
|
33
|
+
when :none then Null.new
|
|
34
|
+
when :aes_256_gcm then AES256GCM.new(config.secret_key_bytes)
|
|
35
|
+
else
|
|
36
|
+
raise ConfigurationError, "unknown cipher #{config.cipher.inspect}"
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Generates a fresh key, base64-encoded and ready to paste into an
|
|
41
|
+
# environment variable.
|
|
42
|
+
#
|
|
43
|
+
# @return [String]
|
|
44
|
+
def self.generate_key
|
|
45
|
+
Base64.strict_encode64(SecureRandom.bytes(32))
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Passthrough. Writes readable JSON into the channel: good for a log you
|
|
49
|
+
# want humans to read in the Discord client, wrong for anything else.
|
|
50
|
+
class Null
|
|
51
|
+
def seal(plaintext, aad: nil) = plaintext.to_s
|
|
52
|
+
def open(ciphertext, aad: nil) = ciphertext.to_s
|
|
53
|
+
def seal_binary(plaintext, aad: nil) = plaintext.to_s
|
|
54
|
+
def open_binary(ciphertext, aad: nil) = ciphertext.to_s
|
|
55
|
+
def encrypting? = false
|
|
56
|
+
def overhead = 0
|
|
57
|
+
def binary_overhead = 0
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Authenticated encryption over a compact, markdown-safe envelope:
|
|
61
|
+
#
|
|
62
|
+
# ds1.<iv>.<tag>.<ciphertext> (each field urlsafe base64, unpadded)
|
|
63
|
+
#
|
|
64
|
+
# Base64url is used rather than standard base64 because Discord renders
|
|
65
|
+
# message content as markdown, and the urlsafe alphabet contains no
|
|
66
|
+
# characters the renderer will try to interpret.
|
|
67
|
+
class AES256GCM
|
|
68
|
+
def initialize(key)
|
|
69
|
+
raise ConfigurationError, "encryption key is missing" if key.nil?
|
|
70
|
+
raise ConfigurationError, "encryption key must be 32 bytes" unless key.bytesize == 32
|
|
71
|
+
|
|
72
|
+
@key = key
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def encrypting? = true
|
|
76
|
+
|
|
77
|
+
# Worst-case added length for a given plaintext size, so the packer can
|
|
78
|
+
# decide whether a record still fits in a message before encrypting it.
|
|
79
|
+
#
|
|
80
|
+
# @return [Integer]
|
|
81
|
+
def overhead
|
|
82
|
+
# version tag + 3 separators + base64(iv) + base64(tag), plus the ~4/3
|
|
83
|
+
# expansion of the ciphertext itself, which the packer accounts for.
|
|
84
|
+
VERSION_TAG.bytesize + 3 + b64_len(IV_BYTES) + b64_len(TAG_BYTES)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# @param plaintext [String]
|
|
88
|
+
# @param aad [String, nil] additional authenticated data; bound into the
|
|
89
|
+
# tag but not stored. Pass the channel ID to make a record undecryptable
|
|
90
|
+
# if it is moved to another channel.
|
|
91
|
+
# @return [String]
|
|
92
|
+
def seal(plaintext, aad: nil)
|
|
93
|
+
cipher = OpenSSL::Cipher.new(ALGORITHM).encrypt
|
|
94
|
+
cipher.key = @key
|
|
95
|
+
iv = cipher.random_iv
|
|
96
|
+
cipher.auth_data = aad.to_s
|
|
97
|
+
ciphertext = cipher.update(plaintext.to_s) + cipher.final
|
|
98
|
+
|
|
99
|
+
[VERSION_TAG, encode(iv), encode(cipher.auth_tag), encode(ciphertext)].join(SEPARATOR)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# @param ciphertext [String] an envelope produced by {#seal}
|
|
103
|
+
# @param aad [String, nil] must match the value passed to {#seal}
|
|
104
|
+
# @return [String]
|
|
105
|
+
# @raise [DecryptionError] if the envelope is malformed, was written with
|
|
106
|
+
# a different key, or has been tampered with
|
|
107
|
+
def open(ciphertext, aad: nil)
|
|
108
|
+
version, iv, tag, body = ciphertext.to_s.split(SEPARATOR, 4)
|
|
109
|
+
|
|
110
|
+
unless version == VERSION_TAG && iv && tag && body
|
|
111
|
+
raise DecryptionError, "not a #{VERSION_TAG} envelope"
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
cipher = OpenSSL::Cipher.new(ALGORITHM).decrypt
|
|
115
|
+
cipher.key = @key
|
|
116
|
+
cipher.iv = decode(iv)
|
|
117
|
+
cipher.auth_tag = decode(tag)
|
|
118
|
+
cipher.auth_data = aad.to_s
|
|
119
|
+
cipher.update(decode(body)) + cipher.final
|
|
120
|
+
rescue OpenSSL::Cipher::CipherError
|
|
121
|
+
raise DecryptionError,
|
|
122
|
+
"payload failed authentication: wrong key, wrong channel, or the " \
|
|
123
|
+
"message was edited by someone with Manage Messages"
|
|
124
|
+
rescue ArgumentError => e
|
|
125
|
+
raise DecryptionError, "malformed envelope: #{e.message}"
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Attachments are binary, so they skip base64 entirely.
|
|
129
|
+
#
|
|
130
|
+
# DS1B | iv (12 bytes) | tag (16 bytes) | ciphertext
|
|
131
|
+
#
|
|
132
|
+
# Text envelopes have to survive being message content, which is why they
|
|
133
|
+
# are base64. Paying that 33% on an attachment would mean uploading four
|
|
134
|
+
# bytes for every three stored, against a rate limit that is already the
|
|
135
|
+
# binding constraint.
|
|
136
|
+
BINARY_MAGIC = "DS1B"
|
|
137
|
+
|
|
138
|
+
# @param plaintext [String]
|
|
139
|
+
# @param aad [String, nil]
|
|
140
|
+
# @return [String] binary
|
|
141
|
+
def seal_binary(plaintext, aad: nil)
|
|
142
|
+
cipher = OpenSSL::Cipher.new(ALGORITHM).encrypt
|
|
143
|
+
cipher.key = @key
|
|
144
|
+
iv = cipher.random_iv
|
|
145
|
+
cipher.auth_data = aad.to_s
|
|
146
|
+
ciphertext = cipher.update(plaintext.to_s) + cipher.final
|
|
147
|
+
|
|
148
|
+
(+"").force_encoding(Encoding::BINARY) << BINARY_MAGIC << iv << cipher.auth_tag << ciphertext
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# @param blob [String] binary, as produced by {#seal_binary}
|
|
152
|
+
# @param aad [String, nil]
|
|
153
|
+
# @return [String]
|
|
154
|
+
# @raise [DecryptionError]
|
|
155
|
+
def open_binary(blob, aad: nil)
|
|
156
|
+
iv, tag, body = split_binary(blob)
|
|
157
|
+
|
|
158
|
+
cipher = OpenSSL::Cipher.new(ALGORITHM).decrypt
|
|
159
|
+
cipher.key = @key
|
|
160
|
+
cipher.iv = iv
|
|
161
|
+
cipher.auth_tag = tag
|
|
162
|
+
cipher.auth_data = aad.to_s
|
|
163
|
+
cipher.update(body) + cipher.final
|
|
164
|
+
rescue OpenSSL::Cipher::CipherError
|
|
165
|
+
raise DecryptionError, "attachment failed authentication: wrong key, or the bytes were altered"
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# @return [Integer] bytes added to a binary payload
|
|
169
|
+
def binary_overhead = BINARY_MAGIC.bytesize + IV_BYTES + TAG_BYTES
|
|
170
|
+
|
|
171
|
+
private
|
|
172
|
+
|
|
173
|
+
# @return [Array(String, String, String)] iv, tag and ciphertext
|
|
174
|
+
def split_binary(blob)
|
|
175
|
+
blob = blob.to_s.dup.force_encoding(Encoding::BINARY)
|
|
176
|
+
raise DecryptionError, "not a #{BINARY_MAGIC} payload" unless blob.start_with?(BINARY_MAGIC)
|
|
177
|
+
|
|
178
|
+
offset = BINARY_MAGIC.bytesize
|
|
179
|
+
iv = blob.byteslice(offset, IV_BYTES)
|
|
180
|
+
tag = blob.byteslice(offset + IV_BYTES, TAG_BYTES)
|
|
181
|
+
body = blob.byteslice(offset + IV_BYTES + TAG_BYTES, blob.bytesize) || +""
|
|
182
|
+
|
|
183
|
+
raise DecryptionError, "payload is truncated" if iv.nil? || tag.nil? || tag.bytesize < TAG_BYTES
|
|
184
|
+
|
|
185
|
+
[iv, tag, body]
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def encode(bytes) = Base64.urlsafe_encode64(bytes, padding: false)
|
|
189
|
+
|
|
190
|
+
def decode(string) = Base64.urlsafe_decode64(string)
|
|
191
|
+
|
|
192
|
+
def b64_len(bytes) = ((bytes * 4) / 3.0).ceil
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "base64"
|
|
5
|
+
|
|
6
|
+
module DiscordStore
|
|
7
|
+
# Turns records into message payloads and back.
|
|
8
|
+
#
|
|
9
|
+
# A Discord message holds 2000 characters, and a request that carries one
|
|
10
|
+
# 80-byte record is a request that spent the same rate-limit permit as one
|
|
11
|
+
# carrying twenty. Since permits are the scarce resource, packing is not an
|
|
12
|
+
# optimisation here — it is most of the throughput.
|
|
13
|
+
#
|
|
14
|
+
# Wire format of a message:
|
|
15
|
+
#
|
|
16
|
+
# DS1 i 3 <- header: version, kind, record count
|
|
17
|
+
# <envelope> <- one record per line, base64url so a line break
|
|
18
|
+
# <envelope> can never appear inside one
|
|
19
|
+
# <envelope>
|
|
20
|
+
#
|
|
21
|
+
# A record too large to fit alongside anything else spills to an attachment
|
|
22
|
+
# and the message body becomes a stub:
|
|
23
|
+
#
|
|
24
|
+
# DS1 s 1
|
|
25
|
+
#
|
|
26
|
+
# Bigger than an attachment is the blob store's problem, not the log's.
|
|
27
|
+
class Codec
|
|
28
|
+
HEADER_PREFIX = "DS1"
|
|
29
|
+
KIND_INLINE = "i"
|
|
30
|
+
KIND_SPILL = "s"
|
|
31
|
+
SPILL_FILENAME = "records.ds1"
|
|
32
|
+
SEPARATOR = "\n"
|
|
33
|
+
|
|
34
|
+
# @param cipher [#seal, #open]
|
|
35
|
+
# @param content_budget [Integer] characters we are willing to use
|
|
36
|
+
# @param spill_limit [Integer, nil] max attachment bytes; nil means unlimited
|
|
37
|
+
def initialize(cipher:, content_budget: 1900, spill_limit: nil)
|
|
38
|
+
@cipher = cipher
|
|
39
|
+
@content_budget = content_budget
|
|
40
|
+
@spill_limit = spill_limit
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Packs records into as few messages as will hold them.
|
|
44
|
+
#
|
|
45
|
+
# @param records [Array<Log::Record>]
|
|
46
|
+
# @param aad [String, nil] bound into each envelope's authentication tag
|
|
47
|
+
# @param atomic [Boolean] when true, refuse to split the batch across
|
|
48
|
+
# messages: either it fits in one message or it raises. One message is one
|
|
49
|
+
# atomic append, so this is how a transaction gets all-or-nothing
|
|
50
|
+
# durability on a platform with no transactions.
|
|
51
|
+
# @return [Array<Hash>] each {content:, files:, records:}
|
|
52
|
+
# @raise [PayloadTooLargeError]
|
|
53
|
+
def pack(records, aad: nil, atomic: false)
|
|
54
|
+
messages = []
|
|
55
|
+
batch = Batch.new(inline_budget)
|
|
56
|
+
|
|
57
|
+
records.each do |record|
|
|
58
|
+
envelope = seal(record, aad)
|
|
59
|
+
|
|
60
|
+
if envelope.length > inline_budget
|
|
61
|
+
raise_if_atomic(atomic, "a single record exceeds one message")
|
|
62
|
+
messages << current_message(batch.drain) if batch.any?
|
|
63
|
+
messages << spill_message(record, envelope)
|
|
64
|
+
next
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
unless batch.fits?(envelope)
|
|
68
|
+
raise_if_atomic(atomic, "the batch does not fit in one message")
|
|
69
|
+
messages << current_message(batch.drain)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
batch.add(record, envelope)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
messages << current_message(batch.drain) if batch.any?
|
|
76
|
+
messages
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Reverses {#pack} for one Discord message.
|
|
80
|
+
#
|
|
81
|
+
# @param message [Hash] a Discord message object
|
|
82
|
+
# @param attachment_body [String, nil] the spilled bytes, if the message has
|
|
83
|
+
# a spill attachment the caller has already fetched
|
|
84
|
+
# @param aad [String, nil]
|
|
85
|
+
# @return [Array<Log::Record>]
|
|
86
|
+
def unpack(message, attachment_body: nil, aad: nil)
|
|
87
|
+
content = message["content"].to_s
|
|
88
|
+
header, body = split_header(content)
|
|
89
|
+
return [] if header.nil?
|
|
90
|
+
|
|
91
|
+
raw = if header[:kind] == KIND_SPILL
|
|
92
|
+
unless attachment_body
|
|
93
|
+
raise CorruptRecordError,
|
|
94
|
+
"message #{message["id"]} spilled to an attachment that was not fetched"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
attachment_body
|
|
98
|
+
else
|
|
99
|
+
body
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
lines = raw.split(SEPARATOR).reject(&:empty?)
|
|
103
|
+
|
|
104
|
+
if header[:count] != lines.size
|
|
105
|
+
raise CorruptRecordError,
|
|
106
|
+
"message #{message["id"]} claims #{header[:count]} records but carries #{lines.size}"
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
lines.each_with_index.map do |envelope, position|
|
|
110
|
+
payload = JSON.parse(@cipher.open(envelope, aad: aad))
|
|
111
|
+
Log::Record.from_wire(payload, lsn: message["id"], position: position,
|
|
112
|
+
channel_id: message["channel_id"])
|
|
113
|
+
end
|
|
114
|
+
rescue JSON::ParserError => e
|
|
115
|
+
raise CorruptRecordError, "message #{message["id"]} holds invalid JSON: #{e.message}"
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Whether a message looks like something this library wrote. Cheap enough to
|
|
119
|
+
# run before doing any crypto.
|
|
120
|
+
#
|
|
121
|
+
# @param message [Hash]
|
|
122
|
+
# @return [Boolean]
|
|
123
|
+
def ours?(message)
|
|
124
|
+
message.is_a?(Hash) && message["content"].to_s.start_with?("#{HEADER_PREFIX} ")
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# @param message [Hash]
|
|
128
|
+
# @return [Boolean] whether the payload lives in an attachment
|
|
129
|
+
def spilled?(message)
|
|
130
|
+
header, = split_header(message["content"].to_s)
|
|
131
|
+
header && header[:kind] == KIND_SPILL
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Accumulates records until the next one would overflow a message.
|
|
135
|
+
class Batch
|
|
136
|
+
def initialize(budget)
|
|
137
|
+
@budget = budget
|
|
138
|
+
@pairs = []
|
|
139
|
+
@size = 0
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def any? = @pairs.any?
|
|
143
|
+
|
|
144
|
+
# @return [Boolean] whether +envelope+ still fits alongside what is held
|
|
145
|
+
def fits?(envelope)
|
|
146
|
+
return true if @pairs.empty?
|
|
147
|
+
|
|
148
|
+
@size + envelope.length + SEPARATOR.length <= @budget
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def add(record, envelope)
|
|
152
|
+
@size += envelope.length + (@pairs.empty? ? 0 : SEPARATOR.length)
|
|
153
|
+
@pairs << [record, envelope]
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# @return [Array] the accumulated pairs, resetting the batch
|
|
157
|
+
def drain
|
|
158
|
+
pairs = @pairs
|
|
159
|
+
@pairs = []
|
|
160
|
+
@size = 0
|
|
161
|
+
pairs
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
private
|
|
166
|
+
|
|
167
|
+
def seal(record, aad)
|
|
168
|
+
envelope = @cipher.seal(JSON.generate(record.to_wire), aad: aad)
|
|
169
|
+
|
|
170
|
+
if envelope.include?(SEPARATOR)
|
|
171
|
+
raise CorruptRecordError, "cipher produced a newline; envelopes must be single-line"
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
envelope
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Header plus the newline that follows it, plus one separator per record
|
|
178
|
+
# after the first. Computed against the worst case so a full batch can never
|
|
179
|
+
# overflow the hard 2000-character limit.
|
|
180
|
+
def inline_budget
|
|
181
|
+
@content_budget - (HEADER_PREFIX.length + 8)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def current_message(pairs)
|
|
185
|
+
records = pairs.map(&:first)
|
|
186
|
+
envelopes = pairs.map(&:last)
|
|
187
|
+
|
|
188
|
+
{
|
|
189
|
+
content: [header(KIND_INLINE, records.size), *envelopes].join(SEPARATOR),
|
|
190
|
+
files: [],
|
|
191
|
+
records: records
|
|
192
|
+
}
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def spill_message(record, envelope)
|
|
196
|
+
if @spill_limit && envelope.bytesize > @spill_limit
|
|
197
|
+
raise PayloadTooLargeError,
|
|
198
|
+
"record is #{envelope.bytesize} bytes, over the #{@spill_limit}-byte attachment " \
|
|
199
|
+
"ceiling. Store it as a blob instead of a log record."
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
{
|
|
203
|
+
content: header(KIND_SPILL, 1),
|
|
204
|
+
files: [{ filename: SPILL_FILENAME, content: envelope, content_type: "application/octet-stream" }],
|
|
205
|
+
records: [record]
|
|
206
|
+
}
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def header(kind, count) = "#{HEADER_PREFIX} #{kind} #{count}"
|
|
210
|
+
|
|
211
|
+
def split_header(content)
|
|
212
|
+
header_line, body = content.split(SEPARATOR, 2)
|
|
213
|
+
return [nil, nil] if header_line.nil?
|
|
214
|
+
|
|
215
|
+
prefix, kind, count = header_line.split(" ", 3)
|
|
216
|
+
return [nil, nil] unless prefix == HEADER_PREFIX
|
|
217
|
+
return [nil, nil] unless [KIND_INLINE, KIND_SPILL].include?(kind)
|
|
218
|
+
|
|
219
|
+
[{ kind: kind, count: count.to_i }, body.to_s]
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def raise_if_atomic(atomic, reason)
|
|
223
|
+
return unless atomic
|
|
224
|
+
|
|
225
|
+
raise PayloadTooLargeError,
|
|
226
|
+
"#{reason}, and this batch was requested atomic. One Discord message is the " \
|
|
227
|
+
"largest thing that commits all-or-nothing; split the transaction or accept " \
|
|
228
|
+
"that replay may observe it partially applied."
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
end
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DiscordStore
|
|
4
|
+
# Process-wide settings. Most applications configure this once at boot:
|
|
5
|
+
#
|
|
6
|
+
# DiscordStore.configure do |c|
|
|
7
|
+
# c.i_understand_this_violates_discord_tos = true
|
|
8
|
+
# c.token = ENV.fetch("DISCORD_BOT_TOKEN")
|
|
9
|
+
# c.application_id = ENV.fetch("DISCORD_APPLICATION_ID")
|
|
10
|
+
# c.guild_id = ENV.fetch("DISCORD_GUILD_ID")
|
|
11
|
+
# c.secret_key = ENV.fetch("DISCORD_STORE_KEY") # 32 raw bytes, base64
|
|
12
|
+
# end
|
|
13
|
+
class Configuration
|
|
14
|
+
# Discord's documented global ceiling for a bot token, in requests/second.
|
|
15
|
+
# We aim below it by default; see #global_rate_limit.
|
|
16
|
+
DISCORD_GLOBAL_LIMIT = 50
|
|
17
|
+
|
|
18
|
+
# Maximum characters in a normal message's +content+ field.
|
|
19
|
+
MESSAGE_CONTENT_LIMIT = 2000
|
|
20
|
+
|
|
21
|
+
# Every setting that has a sensible default, and its default.
|
|
22
|
+
DEFAULTS = {
|
|
23
|
+
global_rate_limit: 45,
|
|
24
|
+
quota_timeout: 15.0,
|
|
25
|
+
max_retries: 5,
|
|
26
|
+
open_timeout: 5.0,
|
|
27
|
+
read_timeout: 30.0,
|
|
28
|
+
write_timeout: 30.0,
|
|
29
|
+
user_agent: nil,
|
|
30
|
+
api_base: "https://discord.com/api/v10",
|
|
31
|
+
cipher: :aes_256_gcm,
|
|
32
|
+
content_budget: 1900,
|
|
33
|
+
max_concurrent_transfers: 24,
|
|
34
|
+
chunk_size: nil,
|
|
35
|
+
delete_policy: :tombstone,
|
|
36
|
+
own_messages_only: true,
|
|
37
|
+
logger: nil
|
|
38
|
+
}.freeze
|
|
39
|
+
|
|
40
|
+
# --- Acknowledgement -----------------------------------------------------
|
|
41
|
+
|
|
42
|
+
# Storing application data in Discord messages violates the Discord
|
|
43
|
+
# Developer Terms of Service. Nothing in this library will make a network
|
|
44
|
+
# request until this is explicitly set to true.
|
|
45
|
+
attr_accessor :i_understand_this_violates_discord_tos
|
|
46
|
+
|
|
47
|
+
# --- Credentials and placement -------------------------------------------
|
|
48
|
+
|
|
49
|
+
# Bot token, without the "Bot " prefix.
|
|
50
|
+
attr_accessor :token
|
|
51
|
+
|
|
52
|
+
# The bot's application (user) ID. Used to enforce that we only ever read
|
|
53
|
+
# messages our own bot wrote. Strongly recommended; see #own_messages_only.
|
|
54
|
+
attr_accessor :application_id
|
|
55
|
+
|
|
56
|
+
# The guild that owns the channels we write to.
|
|
57
|
+
attr_accessor :guild_id
|
|
58
|
+
|
|
59
|
+
# Channels used for the append-only log. More than one channel multiplies
|
|
60
|
+
# write throughput, because Discord's harshest message limit is per-channel.
|
|
61
|
+
attr_accessor :log_channel_ids
|
|
62
|
+
|
|
63
|
+
# Channel used for mutable documents (the KV layer).
|
|
64
|
+
attr_accessor :document_channel_id
|
|
65
|
+
|
|
66
|
+
# Channels used for blob chunks (ActiveStorage). Sharded like the log.
|
|
67
|
+
attr_accessor :blob_channel_ids
|
|
68
|
+
|
|
69
|
+
# Channel holding blob manifests. Kept separate from chunks so that a
|
|
70
|
+
# manifest scan does not have to page past gigabytes of chunk messages.
|
|
71
|
+
attr_accessor :manifest_channel_id
|
|
72
|
+
|
|
73
|
+
# --- Crypto --------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
# 32 raw bytes, base64-encoded. Required unless +cipher+ is :none.
|
|
76
|
+
attr_accessor :secret_key
|
|
77
|
+
|
|
78
|
+
# :aes_256_gcm (default) or :none. :none writes readable JSON into the
|
|
79
|
+
# channel, which is useful when you want humans to read the data in the
|
|
80
|
+
# Discord client, and which forfeits the encryption-at-rest that Discord's
|
|
81
|
+
# own developer terms require of anyone storing end-user data.
|
|
82
|
+
attr_accessor :cipher
|
|
83
|
+
|
|
84
|
+
# --- Transport -----------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
attr_accessor :global_rate_limit, :quota_timeout, :max_retries,
|
|
87
|
+
:open_timeout, :read_timeout, :write_timeout,
|
|
88
|
+
:user_agent, :api_base, :logger
|
|
89
|
+
|
|
90
|
+
# Refuse to read any message not authored by +application_id+. This is the
|
|
91
|
+
# line between a storage backend and a scraper, and it is enforced in code
|
|
92
|
+
# rather than in documentation. Leave it on.
|
|
93
|
+
attr_accessor :own_messages_only
|
|
94
|
+
|
|
95
|
+
# --- Encoding ------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
# How many characters of a message's content we are willing to fill. Kept
|
|
98
|
+
# under MESSAGE_CONTENT_LIMIT so that framing overhead can never push a
|
|
99
|
+
# message over the hard limit.
|
|
100
|
+
attr_accessor :content_budget
|
|
101
|
+
|
|
102
|
+
# Concurrency ceiling for blob chunk transfers.
|
|
103
|
+
attr_accessor :max_concurrent_transfers
|
|
104
|
+
|
|
105
|
+
# Bytes per blob chunk. Left nil to be discovered at runtime from the
|
|
106
|
+
# guild's boost tier, because Discord's attachment ceiling has moved
|
|
107
|
+
# several times and hardcoding it is how these libraries break.
|
|
108
|
+
attr_accessor :chunk_size
|
|
109
|
+
|
|
110
|
+
# What to do when a blob or record is deleted:
|
|
111
|
+
#
|
|
112
|
+
# :tombstone — edit the message to a tombstone marker, never delete.
|
|
113
|
+
# Cheapest, and survives the 14-day bulk-delete window.
|
|
114
|
+
# :bulk — bulk-delete where possible, tombstone the rest.
|
|
115
|
+
# :aggressive — delete everything, one request at a time if we must.
|
|
116
|
+
attr_accessor :delete_policy
|
|
117
|
+
|
|
118
|
+
def initialize
|
|
119
|
+
DEFAULTS.each { |name, value| public_send(:"#{name}=", value) }
|
|
120
|
+
@i_understand_this_violates_discord_tos = false
|
|
121
|
+
@log_channel_ids = []
|
|
122
|
+
@blob_channel_ids = []
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# @raise [UnacknowledgedError] if the ToS acknowledgement is missing
|
|
126
|
+
# @raise [ConfigurationError] if a required setting is missing or invalid
|
|
127
|
+
# @return [void]
|
|
128
|
+
def validate!
|
|
129
|
+
raise UnacknowledgedError unless i_understand_this_violates_discord_tos
|
|
130
|
+
raise ConfigurationError, "token is required" if blank?(token)
|
|
131
|
+
|
|
132
|
+
if own_messages_only && blank?(application_id)
|
|
133
|
+
raise ConfigurationError,
|
|
134
|
+
"application_id is required while own_messages_only is enabled " \
|
|
135
|
+
"(it is what makes the check possible). Set it, or explicitly " \
|
|
136
|
+
"set own_messages_only = false and accept what that means."
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
validate_cipher!
|
|
140
|
+
validate_budget!
|
|
141
|
+
self
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# The raw 32-byte encryption key.
|
|
145
|
+
#
|
|
146
|
+
# @return [String, nil]
|
|
147
|
+
def secret_key_bytes
|
|
148
|
+
return nil if cipher == :none
|
|
149
|
+
return nil if blank?(secret_key)
|
|
150
|
+
|
|
151
|
+
require "base64"
|
|
152
|
+
Base64.strict_decode64(secret_key.to_s)
|
|
153
|
+
rescue ArgumentError
|
|
154
|
+
raise ConfigurationError, "secret_key is not valid base64"
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Every channel this configuration knows about, deduplicated.
|
|
158
|
+
#
|
|
159
|
+
# @return [Array<String>]
|
|
160
|
+
def all_channel_ids
|
|
161
|
+
[
|
|
162
|
+
*log_channel_ids,
|
|
163
|
+
*blob_channel_ids,
|
|
164
|
+
document_channel_id,
|
|
165
|
+
manifest_channel_id
|
|
166
|
+
].compact.map(&:to_s).uniq
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
private
|
|
170
|
+
|
|
171
|
+
def validate_cipher!
|
|
172
|
+
unless %i[aes_256_gcm none].include?(cipher)
|
|
173
|
+
raise ConfigurationError, "cipher must be :aes_256_gcm or :none, got #{cipher.inspect}"
|
|
174
|
+
end
|
|
175
|
+
return if cipher == :none
|
|
176
|
+
|
|
177
|
+
raise ConfigurationError, "secret_key is required unless cipher is :none" if blank?(secret_key)
|
|
178
|
+
|
|
179
|
+
bytes = secret_key_bytes
|
|
180
|
+
return if bytes && bytes.bytesize == 32
|
|
181
|
+
|
|
182
|
+
raise ConfigurationError,
|
|
183
|
+
"secret_key must decode to exactly 32 bytes, got #{bytes ? bytes.bytesize : 0}"
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def validate_budget!
|
|
187
|
+
return if content_budget.positive? && content_budget < MESSAGE_CONTENT_LIMIT
|
|
188
|
+
|
|
189
|
+
raise ConfigurationError,
|
|
190
|
+
"content_budget must be between 1 and #{MESSAGE_CONTENT_LIMIT - 1}"
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def blank?(value)
|
|
194
|
+
value.nil? || value.to_s.strip.empty?
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|