distributed-press-api-client 0.3.1 → 0.4.0rc3

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1f9ade4eb6d66abaf9b33a5781a0a3ec1ced7849c59d2c5e23b871b59cd206f2
4
- data.tar.gz: 57c7f2e5e72e49fa7418e850800a4ad420b29e6be1d62cf4bfb25272234c3424
3
+ metadata.gz: ee95dc9deb3cb19084a66ee33ee4970906619c6489ce0bf29b0aa9f90ae99d93
4
+ data.tar.gz: abcb6a90f1e16ad51aac21f15b79067c141f4cdc45edfbddc69704dd235adac7
5
5
  SHA512:
6
- metadata.gz: 5e6d5be220822e44d1397aa1bf034be6141892a847b99cb1dedb8830d56d0064e955f15b95aba51c1f4aba460d0c04195c1d8b45a651f2c328de5571a75f6215
7
- data.tar.gz: b16c1082df61f87468e54ddf0cfd6336f995b45b7c05f6b30e94cff127daa4ea904fbf6188c519efc98c8116a90e9bef424d2985f5641989f2f42ee8b342e387
6
+ metadata.gz: 5d3e98400b29ee2258a29beb1c47fab70a07d1cbdd43a96df9842a507d2ee3f3aaf9d3eeef1dee381967363227fb8af68b0b9f080861feac67595b1a081c43b0
7
+ data.tar.gz: 55291cec6e026520a3e9f111fd80e55b4762aa551f1dcae12015951a01d9d6dd55bbda07394f5f36566e3500abed7673e2a71c90feaf2f4092ef838361d3c8e8
@@ -20,4 +20,4 @@ class DistributedPress
20
20
  end
21
21
  end
22
22
  end
23
- end
23
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'blocklist'
4
+
5
+ class DistributedPress
6
+ module V1
7
+ module Social
8
+ # Manages Social Inbox allowlist
9
+ #
10
+ # @example
11
+ # DistributedPress::V1::Social::Allowlist.new(client: client)
12
+ # DistributedPress::V1::Social::Allowlist.new(client: client, actor: '@sutty@sutty.nl')
13
+ class Allowlist < Blocklist
14
+ ENDPOINT = '/allowlist'
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'client'
4
+
5
+ class DistributedPress
6
+ module V1
7
+ module Social
8
+ # Manages Social Inbox blocklist
9
+ #
10
+ # @example
11
+ # DistributedPress::V1::Social::Blocklist.new(client: client)
12
+ # DistributedPress::V1::Social::Blocklist.new(client: client, actor: '@sutty@sutty.nl')
13
+ class Blocklist
14
+ ACCEPT = %w[text/plain].freeze
15
+ CONTENT_TYPE = 'text/plain'
16
+ ENDPOINT = '/blocklist'
17
+
18
+ # @return [DistributedPress::V1::Social::Client]
19
+ attr_reader :client
20
+
21
+ # @return [String,nil]
22
+ attr_reader :actor
23
+
24
+ # @param :client [DistributedPress::V1::Social::Client]
25
+ # @param :actor [String]
26
+ def initialize(client:, actor: nil)
27
+ @client = client
28
+ @actor = actor
29
+
30
+ @serializer = proc do |body|
31
+ body.join("\n")
32
+ end
33
+
34
+ # @param :body [String,nil]
35
+ # @return [Array<String>]
36
+ @parser =
37
+ proc do |body, _|
38
+ next [] if body.nil?
39
+
40
+ body.split("\n").map(&:strip).reject(&:empty?)
41
+ end
42
+ end
43
+
44
+ # Obtains the current blocklist
45
+ #
46
+ # @return [Array<String>]
47
+ def get
48
+ client.get(endpoint: endpoint, parser: @parser, content_type: CONTENT_TYPE, accept: ACCEPT)
49
+ end
50
+
51
+ # Sends an array of instances and accounts to be blocked
52
+ #
53
+ # @param :list [Array<String>]
54
+ # @return [HTTParty::Response]
55
+ def post(list:)
56
+ client.post(endpoint: endpoint, body: list, serializer: @serializer, parser: @parser,
57
+ content_type: CONTENT_TYPE, accept: ACCEPT)
58
+ end
59
+
60
+ # Removes instances and accounts from the blocklist
61
+ #
62
+ # @param :list [Array<String>]
63
+ # @return [HTTParty::Response]
64
+ def delete(list:)
65
+ client.delete(endpoint: endpoint, body: list, serializer: @serializer, parser: @parser,
66
+ content_type: CONTENT_TYPE, accept: ACCEPT)
67
+ end
68
+
69
+ def endpoint
70
+ @endpoint ||= "/v1/#{actor}#{self.class::ENDPOINT}".squeeze('/')
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
@@ -1,10 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'httparty'
4
+ require 'httparty/cache'
4
5
  require 'openssl'
5
6
  require 'time'
6
7
  require 'digest/sha2'
7
8
  require 'uri'
9
+ require_relative '../../version'
10
+ require_relative 'signed_headers'
8
11
 
9
12
  class DistributedPress
10
13
  module V1
@@ -17,22 +20,33 @@ class DistributedPress
17
20
  # @see {https://github.com/mastodon/mastodon/blob/main/app/lib/request.rb}
18
21
  class Client
19
22
  include ::HTTParty
23
+ include ::HTTParty::Cache
20
24
 
21
- # Signing algorithm
22
- #
23
- # @todo is it possible to use other algorithms?
24
- ALGORITHM = 'rsa-sha256'
25
- # Required by HTTP Signatures
26
- REQUEST_TARGET = '(request-target)'
27
- # Headers included in the signature
28
- SIGNABLE_HEADERS = [REQUEST_TARGET, 'Host', 'Date', 'Digest']
29
- # Content types
30
- CONTENT_TYPES = %w[application/ld+json application/activity+json]
25
+ class Error < StandardError; end
26
+ class ContentTooLargeError < Error; end
27
+ class BrotliUnsupportedError < Error; end
28
+
29
+ # Always cache
30
+ caching true
31
+
32
+ # Content types sent and accepted
33
+ ACCEPT = %w[application/activity+json application/ld+json application/json].freeze
34
+ CONTENT_TYPE = 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'
35
+
36
+ ACCEPT.each do |accept|
37
+ HTTParty::Parser::SupportedFormats[accept] = :json
38
+ end
31
39
 
32
40
  # API URL
41
+ #
33
42
  # @return [String]
34
43
  attr_reader :url
35
44
 
45
+ # API URI
46
+ #
47
+ # @return [URI]
48
+ attr_reader :uri
49
+
36
50
  # RSA key size
37
51
  #
38
52
  # @return [Integer]
@@ -43,36 +57,57 @@ class DistributedPress
43
57
  # @return [String]
44
58
  attr_reader :public_key_url
45
59
 
60
+ # Logger
61
+ #
62
+ # @return [Logger]
63
+ attr_reader :logger
64
+
46
65
  # @param :url [String] Social Distributed Press URL
47
66
  # @param :public_key [String] URL where public key is available
48
67
  # @param :private_key_pem [String] RSA Private key, PEM encoded
49
68
  # @param :key_size [Integer]
50
69
  # @param :logger [Logger]
51
- def initialize(url: 'https://social.distributed.press', public_key_url:, private_key_pem: nil, key_size: 2048, logger: nil)
52
- self.class.default_options[:base_uri] = @url = HTTParty.normalize_base_uri(url)
53
- self.class.default_options[:logger] = logger
70
+ # @param :cache_store [HTTParty::Cache::Store::Abstract,Symbol]
71
+ def initialize(public_key_url:, url: 'https://social.distributed.press', private_key_pem: nil, key_size: 2048,
72
+ logger: nil, cache_store: :memory)
73
+ self.class.default_options[:logger] = @logger = logger
54
74
  self.class.default_options[:log_level] = :debug
75
+ self.class.cache_store cache_store
55
76
 
77
+ @url = HTTParty.normalize_base_uri(url)
56
78
  @public_key_url = public_key_url
57
79
  @key_size = key_size
58
80
  @private_key_pem = private_key_pem
59
-
60
- CONTENT_TYPES.each do |content_type|
61
- HTTParty::Parser::SupportedFormats[content_type] = :json
81
+ @uri = URI.parse(url)
82
+ @serializer ||= proc do |body|
83
+ body.to_json
62
84
  end
63
85
  end
64
86
 
87
+ def _dump(_)
88
+ Marshal.dump({
89
+ public_key_url: public_key_url,
90
+ url: url,
91
+ private_key_pem: @private_key_pem,
92
+ key_size: key_size,
93
+ cache_store: self.class.cache_store
94
+ })
95
+ end
96
+
97
+ def self._load(hash)
98
+ new(**Marshal.load(hash))
99
+ end
100
+
65
101
  # GET request
66
102
  #
67
103
  # @param endpoint [String]
68
- # @return [Hash]
69
- def get(endpoint:)
70
- headers = default_headers
71
-
72
- add_request_specific_headers! headers, 'get', endpoint
73
- sign_headers! headers
74
-
75
- self.class.get(endpoint, headers: headers)
104
+ # @param parser [HTTParty::Parser,Proc]
105
+ # @param content_type [String]
106
+ # @param accept [Array<String>]
107
+ # @return [HTTParty::Response]
108
+ def get(endpoint:, parser: HTTParty::Parser, content_type: CONTENT_TYPE, accept: ACCEPT)
109
+ perform_signed_request method: Net::HTTP::Get, endpoint: endpoint, parser: parser, content_type: content_type,
110
+ accept: accept
76
111
  end
77
112
 
78
113
  # POST request
@@ -80,16 +115,45 @@ class DistributedPress
80
115
  # @todo Use DRY-schemas
81
116
  # @param endpoint [String]
82
117
  # @param body [Hash]
83
- # @return [Hash]
84
- def post(endpoint:, body:)
85
- body = body.to_json
86
- headers = default_headers
118
+ # @param serializer [Proc]
119
+ # @param parser [HTTParty::Parser,Proc]
120
+ # @param content_type [String]
121
+ # @param accept [Array<String>]
122
+ # @return [HTTParty::Response]
123
+ def post(endpoint:, body: nil, serializer: @serializer, parser: HTTParty::Parser, content_type: CONTENT_TYPE,
124
+ accept: ACCEPT)
125
+ perform_signed_request method: Net::HTTP::Post, endpoint: endpoint, body: body, serializer: serializer,
126
+ parser: parser, content_type: content_type, accept: accept
127
+ end
87
128
 
88
- add_request_specific_headers! headers, 'post', endpoint
89
- checksum_body! body, headers
90
- sign_headers! headers
129
+ # PUT request
130
+ #
131
+ # @param endpoint [String]
132
+ # @param body [Hash]
133
+ # @param serializer [Proc]
134
+ # @param parser [HTTParty::Parser,Proc]
135
+ # @param content_type [String]
136
+ # @param accept [Array<String>]
137
+ # @return [HTTParty::Response]
138
+ def put(endpoint:, body: nil, serializer: @serializer, parser: HTTParty::Parser, content_type: CONTENT_TYPE,
139
+ accept: ACCEPT)
140
+ perform_signed_request method: Net::HTTP::Put, endpoint: endpoint, body: body, serializer: serializer,
141
+ parser: parser, content_type: content_type, accept: accept
142
+ end
91
143
 
92
- self.class.post(endpoint, body: body, headers: headers)
144
+ # DELETE request with optional body
145
+ #
146
+ # @param endpoint [String]
147
+ # @param body [Hash]
148
+ # @param serializer [Proc]
149
+ # @param parser [HTTParty::Parser,Proc]
150
+ # @param content_type [String]
151
+ # @param accept [Array<String>]
152
+ # @return [HTTParty::Response]
153
+ def delete(endpoint:, body: nil, serializer: @serializer, parser: HTTParty::Parser, content_type: CONTENT_TYPE,
154
+ accept: ACCEPT)
155
+ perform_signed_request method: Net::HTTP::Delete, endpoint: endpoint, body: body, serializer: serializer,
156
+ parser: parser, content_type: content_type, accept: accept
93
157
  end
94
158
 
95
159
  # Loads or generates a private key
@@ -115,84 +179,154 @@ class DistributedPress
115
179
 
116
180
  private
117
181
 
118
- def default_headers
119
- {
182
+ # Perform request
183
+ #
184
+ # @param method [Net::HTTP::Get,Net::HTTP::Post,Net::HTTP::Put,Net::HTTP::Delete]
185
+ # @param endpoint [String]
186
+ # @param body [Hash]
187
+ # @param serializer [Proc]
188
+ # @param parser [HTTParty::Parser,Proc]
189
+ # @param content_type [String]
190
+ # @param accept [Array<String>]
191
+ # @return [HTTParty::Response]
192
+ def perform_signed_request(method:, endpoint:, body: nil, serializer: @default_serializer, parser: HTTParty::Parser,
193
+ content_type: CONTENT_TYPE, accept: ACCEPT)
194
+ headers = default_headers(content_type: content_type, accept: accept)
195
+
196
+ unless body.nil?
197
+ body = serializer.call(body)
198
+ checksum_body!(body, headers)
199
+ end
200
+
201
+ # Mimics HTTParty.perform_request, but processing the header
202
+ # after the request is instantiated. No need to process
203
+ # cookies for now. Uses the public key URL as a caching key so
204
+ # we revalidate access on shared caches.
205
+ options = { body: body, headers: headers, base_uri: url, parser: parser, stream_body: true, cache_key: public_key_url }
206
+ options = HTTParty::ModuleInheritableAttributes.hash_deep_dup(self.class.default_options).merge(options)
207
+ response_body = ''.dup
208
+
209
+ HTTParty::Request.new(method, endpoint, options).tap do |request|
210
+ headers.request = request
211
+ end.perform do |fragment|
212
+ fragment_to_body!(fragment, response_body)
213
+ end.tap do |response|
214
+ next if response_body.empty?
215
+
216
+ fix_response!(response, response_body)
217
+ end
218
+ end
219
+
220
+ # Default headers
221
+ #
222
+ # @param :content_type [String]
223
+ # @param :accept [Array<String>]
224
+ # @return [SignedHeaders]
225
+ def default_headers(content_type: CONTENT_TYPE, accept: ACCEPT)
226
+ SignedHeaders[
120
227
  'User-Agent' => "DistributedPress/#{DistributedPress::VERSION}",
121
- 'Content-Type' => 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
122
- 'Host' => host
123
- }
228
+ 'Content-Type' => content_type,
229
+ 'Accept' => accept.join(', '),
230
+ 'Host' => host,
231
+ 'Accept-Encoding' => "#{'br;q=1.0,' if brotli?}gzip;q=1.0,deflate;q=0.6,identity;q=0.3",
232
+ 'Date' => Time.now.utc.httpdate
233
+ ].tap do |headers|
234
+ headers.private_key = private_key
235
+ headers.public_key_url = public_key_url
236
+ end
124
237
  end
125
238
 
126
- # HTTP Signatures
239
+ # Generate a checksum for the request body
127
240
  #
128
- # @see {https://docs.joinmastodon.org/spec/security/}
241
+ # @param :body [String]
129
242
  # @param :headers [Hash]
130
- def sign_headers!(headers)
131
- headers['Signature'] = {
132
- 'keyId' => public_key_url,
133
- 'algorithm' => ALGORITHM,
134
- 'headers' => signable_headers(headers),
135
- 'signature' => signed_headers(headers)
136
- }.map do |key, value|
137
- "#{key}=\"#{value}\""
138
- end.join(',')
139
-
140
- headers.delete REQUEST_TARGET
243
+ def checksum_body!(body, headers)
244
+ headers['Digest'] = "SHA-256=#{Digest::SHA256.base64digest body}"
141
245
 
142
246
  nil
143
247
  end
144
248
 
145
- # List of headers to be signed, removing headers that don't
146
- # exist on the request.
249
+ # If the endpoint is on a subdirectory, we need the absolute
250
+ # path for signing
147
251
  #
252
+ # @param :endpoint [String]
148
253
  # @return [String]
149
- def signable_headers(headers)
150
- (SIGNABLE_HEADERS & headers.keys).join(' ').downcase
254
+ def absolute_endpoint(endpoint)
255
+ "#{uri.path}/#{endpoint}".squeeze('/')
151
256
  end
152
257
 
153
- # Sign headers
154
- #
155
- # @param :headers [Hash]
156
- # @return [String]
157
- def signed_headers(headers)
158
- Base64.strict_encode64(
159
- private_key.sign(
160
- OpenSSL::Digest.new('SHA256'),
161
- signature_content(headers)
162
- )
163
- )
258
+ # Brotli available
259
+ def brotli?
260
+ begin
261
+ require 'brs'
262
+ rescue LoadError => e
263
+ if logger
264
+ logger.warn e.message
265
+ else
266
+ puts e
267
+ end
268
+ end
269
+
270
+ defined? ::BRS
164
271
  end
165
272
 
166
- # Generates a string to be signed
273
+ # Inflates a Brotli compressed fragment. A fragment could be
274
+ # small enough to contain a huge inflated payload. In our
275
+ # tests, 2GB of zeroes were compressed to a 1.6KB fragment.
167
276
  #
168
- # @param :headers [Hash]
169
- # @return [String]
170
- def signature_content(headers)
171
- headers.slice(*SIGNABLE_HEADERS).map do |key, value|
172
- "#{key.downcase}: #{value}"
173
- end.join("\n")
277
+ # @todo Maybe each_char is too slow?
278
+ # @param fragment [HTTParty::ResponseFragment]
279
+ # @param append_to [String]
280
+ def inflate_fragment!(fragment, append_to)
281
+ fragment_io = StringIO.new(fragment)
282
+
283
+ BRS::Stream::Reader.new(fragment_io, source_buffer_length: 256,
284
+ destination_buffer_length: 1024).each_char do |char|
285
+ append_to << char
286
+
287
+ raise ContentTooLargeError if append_to.bytesize > 1_000_000
288
+ end
174
289
  end
175
290
 
176
- # Generate a checksum for the request body
291
+ # Collect fragments into body by checking the bytesize and
292
+ # failing if it gets too big.
177
293
  #
178
- # @param :body [String]
179
- # @param :headers [Hash]
180
- def checksum_body!(body, headers)
181
- headers['Digest'] = "SHA-256=#{Digest::SHA256.base64digest body}"
294
+ # @param :fragment [HTTParty::ResponseFragment]
295
+ # @param :append_to [String]
296
+ def fragment_to_body!(fragment, append_to)
297
+ case fragment.http_response['content-encoding']
298
+ when 'br'
299
+ # Raise an error rather than returning an empty body
300
+ unless brotli?
301
+ raise BrotliUnsupportedError,
302
+ 'Server sent brotli-encoded response but ruby-brs gem is missing'
303
+ end
182
304
 
183
- nil
305
+ inflate_fragment!(fragment, append_to)
306
+ else
307
+ append_to << fragment
308
+ end
309
+
310
+ raise ContentTooLargeError if append_to.bytesize > 1_000_000
311
+ rescue ContentTooLargeError
312
+ raise ContentTooLargeError, "Content too large #{append_to.bytesize} bytes!"
184
313
  end
185
314
 
186
- # Headers specific to a single request
315
+ # Re-adds a streamed body to the response and caches it again so
316
+ # it can carry the body
187
317
  #
188
- # @param :headers [Hash] Headers
189
- # @param :verb [String] HTTP verb
190
- # @param :endpoint [String] Path
191
- def add_request_specific_headers!(headers, verb, endpoint)
192
- headers['Date'] = Time.now.utc.httpdate
193
- headers[REQUEST_TARGET] = "#{verb} #{endpoint}"
318
+ # @param response [HTTParty::Response]
319
+ # @param body [String]
320
+ def fix_response!(response, body)
321
+ request = response.request
322
+ parser = request.options[:parser]
323
+ format = request.format || :plain
194
324
 
195
- nil
325
+ response.instance_variable_set(:@body, body)
326
+ response.instance_variable_set(:@parsed_block, -> { parser.call(body, format) })
327
+ response.instance_variable_set(:@parsed_response, nil)
328
+
329
+ self.class.cache_store.set(response.cache_key, response)
196
330
  end
197
331
  end
198
332
  end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'addressable'
4
+ require_relative 'reference'
5
+ require_relative 'referenced_object'
6
+
7
+ class DistributedPress
8
+ module V1
9
+ module Social
10
+ # Fetches ActivityStreams from different instances by
11
+ # instantiating clients.
12
+ class Dereferencer
13
+ # @return [DistributedPress::V1::Social::Client]
14
+ attr_reader :client
15
+
16
+ # We only need the client
17
+ def _dump(_)
18
+ Marshal.dump(client)
19
+ end
20
+
21
+ def self._load(client)
22
+ new(client: Marshal.load(client))
23
+ end
24
+
25
+ # @param :client [DistributedPress::V1::Social::Client]
26
+ def initialize(client:)
27
+ @client = client
28
+ @parser =
29
+ proc do |body, format|
30
+ next HTTParty::Parser.call(body, format || :plain) unless body&.starts_with? '{'
31
+
32
+ ReferencedObject.new(object: HTTParty::Parser.call(body, :json), dereferencer: self)
33
+ end
34
+ end
35
+
36
+ # Fetch a URI
37
+ #
38
+ # @param :uri [String, Addressable::URI]
39
+ # @return [HTTParty::Response]
40
+ def get(uri:)
41
+ uri = uris(uri)
42
+
43
+ clients(uri).get(endpoint: uri.path, parser: @parser)
44
+ end
45
+
46
+ # Gets a client for a URI
47
+ #
48
+ # @param :uri [Addressable::URI]
49
+ # @return [DistributedPress::V1::Social::Client]
50
+ def clients(uri)
51
+ @@clients ||= {}
52
+ @@clients[uri.origin] ||=
53
+ client.class.new(
54
+ url: uri.origin,
55
+ public_key_url: client.public_key_url,
56
+ private_key_pem: client.private_key.to_s,
57
+ logger: client.logger,
58
+ cache_store: client.class.cache_store
59
+ )
60
+ end
61
+
62
+ # Gets a reference for a URI and indexes it by the complete
63
+ # and normalized URI
64
+ #
65
+ # @param :uri [String, Addressable::URI]
66
+ # @return [DistributedPress::V1::Social::Reference]
67
+ def references(uri)
68
+ @@references ||= {}
69
+ @@references[uri.to_s] ||= Reference.new(uri: uri.to_s, dereferencer: self)
70
+ end
71
+
72
+ # Make sure we're getting a normalized Addressable::URI
73
+ #
74
+ # @param :uri [String, Addressable::URI]
75
+ # @return [Addressable::URI]
76
+ def uris(uri)
77
+ @@uris ||= {}
78
+ @@uris[uri.to_s] ||=
79
+ (if uri.is_a? Addressable::URI
80
+ uri
81
+ else
82
+ Addressable::URI.parse(uri)
83
+ end).normalize
84
+ end
85
+
86
+ end
87
+ end
88
+ end
89
+ end