distributed-press-api-client 0.3.1 → 0.4.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1f9ade4eb6d66abaf9b33a5781a0a3ec1ced7849c59d2c5e23b871b59cd206f2
4
- data.tar.gz: 57c7f2e5e72e49fa7418e850800a4ad420b29e6be1d62cf4bfb25272234c3424
3
+ metadata.gz: 1a027ef2456449498ff03679402ac4a0f3689656cd5990e0aa9498b07e86beea
4
+ data.tar.gz: 1117e426318b07a0bbb833713b6e15fd5c614490d4ff05d1c29cac4cbbb8fc1a
5
5
  SHA512:
6
- metadata.gz: 5e6d5be220822e44d1397aa1bf034be6141892a847b99cb1dedb8830d56d0064e955f15b95aba51c1f4aba460d0c04195c1d8b45a651f2c328de5571a75f6215
7
- data.tar.gz: b16c1082df61f87468e54ddf0cfd6336f995b45b7c05f6b30e94cff127daa4ea904fbf6188c519efc98c8116a90e9bef424d2985f5641989f2f42ee8b342e387
6
+ metadata.gz: c462c24bd87a73557218beefe37b3194016ee1462fbd061155018b09c068fba019027512c17eee71c8096f83bd10a14a81aaca06d6597efa9218e615341f24de
7
+ data.tar.gz: b9424cca5bf2d55085298e5205df63bfe6813b1a3874f3854c85a1cd9019a4207284468b5337081e2e73b7fbe7b9e8f6ceccc1e289d7a7a28fd856674ca5acb1
@@ -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 }
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
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'client'
4
+ require_relative 'schemas/webhook'
5
+
6
+ class DistributedPress
7
+ module V1
8
+ module Social
9
+ # Manages the actor's hooks on the Social Inbox
10
+ class Hook
11
+ class Error < StandardError; end
12
+ class ValidationError < Error; end
13
+ class EventNotValidError < Error; end
14
+
15
+ ACCEPT = %w[text/plain].freeze
16
+ CONTENT_TYPE = 'text/plain'
17
+ EVENTS = %w[moderationqueued onapproved onrejected].freeze
18
+
19
+ # @return [DistributedPress::V1::Social::Client]
20
+ attr_reader :client
21
+
22
+ # @return [String]
23
+ attr_reader :actor
24
+
25
+ # @param :client [DistributedPress::V1::Social::Client]
26
+ # @param :actor [String]
27
+ def initialize(client:, actor:)
28
+ @client = client
29
+ @actor = actor
30
+ # The serializer validates the body format and raises an
31
+ # exception if it contains errors.
32
+ @serializer = proc do |body|
33
+ body.tap do |b|
34
+ next if b.errors.empty?
35
+
36
+ raise ValidationError, body.errors.to_h.map do |key, messages|
37
+ messages.map do |message|
38
+ "#{key} #{message}"
39
+ end
40
+ end.flatten.join(', ')
41
+ end.to_h.to_json
42
+ end
43
+
44
+ # XXX: format is nil but should be :json
45
+ @parser = proc do |body, format|
46
+ next HTTParty::Parser.call(body, format || :plain) unless body.starts_with? '{'
47
+
48
+ json = HTTParty::Parser.call(body, :json)
49
+
50
+ DistributedPress::V1::Social::Schemas::Webhook.new.call(json)
51
+ end
52
+ end
53
+
54
+ # Gets a hook and validates return, or 404 when not found
55
+ #
56
+ # @param :event [String]
57
+ # @return [HTTParty::Response]
58
+ def get(event:)
59
+ validate_event! event
60
+
61
+ client.get(endpoint: "#{endpoint}/#{event}", parser: @parser)
62
+ end
63
+
64
+ # Creates a webhook
65
+ #
66
+ # @param :event [String]
67
+ # @param :hook [DistributedPress::V1::Social::Schemas::Webhook]
68
+ # @return [HTTParty::Response]
69
+ def put(event:, hook:)
70
+ validate_event! event
71
+
72
+ client.put(endpoint: "#{endpoint}/#{event}", body: hook, serializer: @serializer, parser: @parser)
73
+ end
74
+
75
+ # Removes a hook for an event
76
+ #
77
+ # @param :event [String]
78
+ # @return [HTTParty::Response]
79
+ def delete(event:)
80
+ validate_event! event
81
+
82
+ client.delete(endpoint: "#{endpoint}/#{event}", serializer: @serializer, parser: @parser)
83
+ end
84
+
85
+ # Endpoint
86
+ #
87
+ # @return [String]
88
+ def endpoint
89
+ @endpoint ||= "/v1/#{actor}/hooks"
90
+ end
91
+
92
+ private
93
+
94
+ def validate_event!(event)
95
+ raise EventNotValidError, "#{event} must be one of #{EVENTS.join(', ')}" unless EVENTS.include? event
96
+ end
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'uri'
4
+ require_relative 'client'
5
+
6
+ class DistributedPress
7
+ module V1
8
+ module Social
9
+ # Manages the actor's inbox on the Social Inbox
10
+ class Inbox
11
+ # @return [DistributedPress::V1::Social::Client]
12
+ attr_reader :client
13
+
14
+ # @return [String]
15
+ attr_reader :actor
16
+
17
+ # @param :client [DistributedPress::V1::Social::Client]
18
+ # @param :actor [String]
19
+ def initialize(client:, actor:)
20
+ @client = client
21
+ @actor = actor
22
+ end
23
+
24
+ # Creates a Social Inbox by uploading the keypair to it.
25
+ #
26
+ # @param actor_url [String] The URL where the Actor profile is hosted
27
+ # @return [HTTParty::Response]
28
+ def create(actor_url)
29
+ inbox_body = {
30
+ 'actorUrl' => actor_url,
31
+ 'publicKeyId' => "#{actor_url}#main-key",
32
+ 'keypair' => {
33
+ 'publicKeyPem' => client.public_key.public_to_pem,
34
+ 'privateKeyPem' => client.private_key.export
35
+ }
36
+ }
37
+
38
+ client.post(endpoint: "/v1/#{actor}", body: inbox_body)
39
+ end
40
+
41
+ # Get the actor's inbox
42
+ #
43
+ # @return [HTTParty::Response]
44
+ def get
45
+ client.get(endpoint: endpoint)
46
+ end
47
+
48
+ # Send an activity to the actor's inbox. This is typically done
49
+ # by other actors though, not ourselves, so it could be used to
50
+ # send directly to another Actor's Social Inbox.
51
+ #
52
+ # @param :activity [Hash]
53
+ # @return [HTTParty::Response]
54
+ def post(activity:)
55
+ client.post(endpoint: endpoint, body: activity)
56
+ end
57
+
58
+ # Reject an activity queued on the inbox
59
+ #
60
+ # @param :id [String] Activity ID
61
+ # @return [HTTParty::Response]
62
+ def reject(id:)
63
+ client.delete(endpoint: "#{endpoint}/#{URI.encode_uri_component(id)}")
64
+ end
65
+
66
+ # Accept an activity queued on the inbox
67
+ #
68
+ # @param :id [String] Activity ID
69
+ # @return [HTTParty::Response]
70
+ def accept(id:)
71
+ client.post(endpoint: "#{endpoint}/#{URI.encode_uri_component(id)}", body: {})
72
+ end
73
+
74
+ # Inbox
75
+ #
76
+ # @return [String]
77
+ def endpoint
78
+ @endpoint ||= "/v1/#{actor}/inbox"
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'uri'
4
+ require_relative 'client'
5
+
6
+ class DistributedPress
7
+ module V1
8
+ module Social
9
+ # Manages the actor's outbox on the Social Inbox
10
+ class Outbox
11
+ # @return [DistributedPress::V1::Social::Client]
12
+ attr_reader :client
13
+
14
+ # @return [String]
15
+ attr_reader :actor
16
+
17
+ # @param :client [DistributedPress::V1::Social::Client]
18
+ # @param :actor [String]
19
+ def initialize(client:, actor:)
20
+ @client = client
21
+ @actor = actor
22
+ end
23
+
24
+ # Send an activity to the actor's outbox. The Social Inbox will
25
+ # take care of sending it to the audiences.
26
+ #
27
+ # @param :activity [Hash]
28
+ # @return [HTTParty::Response]
29
+ def post(activity:)
30
+ client.post(endpoint: endpoint, body: activity)
31
+ end
32
+
33
+ # Get an activity queued on the outbox
34
+ #
35
+ # @param :id [String] Activity ID
36
+ # @return [HTTParty::Response]
37
+ def get(id:)
38
+ client.get(endpoint: "#{endpoint}/#{URI.encode_uri_component(id)}")
39
+ end
40
+
41
+ # Outbox
42
+ #
43
+ # @return [String]
44
+ def endpoint
45
+ @endpoint ||= "/v1/#{actor}/outbox"
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ class DistributedPress
4
+ module V1
5
+ module Social
6
+ # A lazy loaded reference to a remote object that can access its
7
+ # attributes directly.
8
+ class Reference
9
+ extend Forwardable
10
+
11
+ # @return [String]
12
+ attr_reader :uri
13
+
14
+ # @return [DistributedPress::V1::Social::Dereferencer]
15
+ attr_reader :dereferencer
16
+
17
+ # @param :uri [String]
18
+ # @param :dereferencer [DistributedPress::V1::Social::Dereferencer]
19
+ def initialize(uri:, dereferencer:)
20
+ @uri = uri
21
+ @dereferencer = dereferencer
22
+ end
23
+
24
+ # Fetches the remote object once
25
+ #
26
+ # @return [HTTParty::Response]
27
+ def object
28
+ @object ||= dereferencer.get(uri: uri)
29
+ end
30
+
31
+ def inspect
32
+ "#{self.class.name}(#{uri})"
33
+ end
34
+
35
+ def_delegators :object, :[], :dig, :to_h, :to_json
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ class DistributedPress
4
+ module V1
5
+ module Social
6
+ # An object with external references
7
+ class ReferencedObject
8
+ extend Forwardable
9
+
10
+ REFERENTIABLE_ATTRIBUTES =
11
+ %w[
12
+ actor
13
+ owner
14
+ attributedTo
15
+ cc
16
+ inReplyTo
17
+ object
18
+ replies
19
+ to
20
+ publicKey
21
+
22
+ alsoKnownAs
23
+ devices
24
+ featured
25
+ featuredTags
26
+ followers
27
+ following
28
+ inbox
29
+ movedTo
30
+ outbox
31
+
32
+ first
33
+ items
34
+ next
35
+ orderedItems
36
+ partOf
37
+ prev
38
+ ].freeze
39
+
40
+ attr_reader :object
41
+ attr_reader :dereferencer
42
+ attr_reader :referenced
43
+
44
+ def_delegators :referenced, :[], :dig, :to_h, :to_json
45
+
46
+ def initialize(object:, dereferencer:)
47
+ @object = object
48
+ @dereferencer = dereferencer
49
+ @referenced = HTTParty::ModuleInheritableAttributes.hash_deep_dup(object)
50
+ reference_object! referenced
51
+ end
52
+
53
+ def _dump(_)
54
+ Marshal.dump([object, dereferencer])
55
+ end
56
+
57
+ def self._load(array)
58
+ object, dereferencer = Marshal.load(array)
59
+
60
+ new(object: object, dereferencer: dereferencer)
61
+ end
62
+
63
+ private
64
+
65
+ def reference_object!(object)
66
+ REFERENTIABLE_ATTRIBUTES.each do |attribute|
67
+ next unless object.key? attribute
68
+
69
+ case object[attribute]
70
+ when Array
71
+ object[attribute].map! do |o|
72
+ case o
73
+ when Hash
74
+ reference_object!(o)
75
+ o
76
+ when String then dereferencer.references(dereferencer.uris(o))
77
+ end
78
+ end
79
+ when Hash
80
+ reference_object!(object[attribute])
81
+ when String
82
+ object[attribute] = dereferencer.references(dereferencer.uris(object[attribute]))
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'dry-schema'
4
+ require_relative '../../../../dry/schema/processor_decorator'
5
+ require_relative '../../../../dry/schema/result_decorator'
6
+
7
+ class DistributedPress
8
+ module V1
9
+ module Social
10
+ # WebhookSchema
11
+ module Schemas
12
+ class Webhook < Dry::Schema::JSON
13
+ METHODS = %w[GET POST PUT DELETE].freeze
14
+
15
+ define do
16
+ required(:url).value(:uri_rfc3986?)
17
+ required(:method).value(included_in?: METHODS)
18
+ required(:headers).hash
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ class DistributedPress
4
+ module V1
5
+ module Social
6
+ # Headers need to be signed by knowing the HTTP method (verb) and
7
+ # path. Since we're using caching, we need to change the
8
+ # signature after it's changed.
9
+ class SignedHeaders < Hash
10
+ # Signing algorithm
11
+ #
12
+ # @todo is it possible to use other algorithms?
13
+ ALGORITHM = 'rsa-sha256'
14
+
15
+ # Required by HTTP Signatures
16
+ REQUEST_TARGET = '(request-target)'
17
+
18
+ # Headers included in the signature
19
+ # rubocop:disable Style/MutableConstant
20
+ SIGNABLE_HEADERS = [REQUEST_TARGET, 'Host', 'Date', 'Digest']
21
+ # rubocop:enable Style/MutableConstant
22
+
23
+ # @return [HTTParty::Request]
24
+ attr_accessor :request
25
+
26
+ # @return [OpenSSL::PKey::RSA]
27
+ attr_accessor :private_key
28
+
29
+ # @return [String]
30
+ attr_accessor :public_key_url
31
+
32
+ # Takes advantage of HTTParty::Request.setup_raw_request running
33
+ # to_hash on the headers, so we sign as soon as we're ready to
34
+ # send the request.
35
+ #
36
+ # @return [Hash]
37
+ def to_hash
38
+ request_target!
39
+ sign_headers!
40
+
41
+ # xxx: converts to an actual Hash to facilitate marshaling
42
+ super.to_h
43
+ end
44
+
45
+ private
46
+
47
+ # @return [String]
48
+ def verb
49
+ request.http_method.name.split('::').last.downcase
50
+ end
51
+
52
+ def request_target!
53
+ self[REQUEST_TARGET] = "#{verb} #{request.path}"
54
+ end
55
+
56
+ # HTTP Signatures
57
+ #
58
+ # @see {https://docs.joinmastodon.org/spec/security/}
59
+ # @param :headers [Hash]
60
+ def sign_headers!
61
+ self['Signature'] = {
62
+ 'keyId' => public_key_url,
63
+ 'algorithm' => ALGORITHM,
64
+ 'headers' => signable_headers,
65
+ 'signature' => signed_headers
66
+ }.map do |key, value|
67
+ "#{key}=\"#{value}\""
68
+ end.join(',')
69
+
70
+ delete REQUEST_TARGET
71
+
72
+ nil
73
+ end
74
+
75
+ # List of headers to be signed, removing headers that don't
76
+ # exist on the request.
77
+ #
78
+ # @return [String]
79
+ def signable_headers
80
+ (SIGNABLE_HEADERS & keys).join(' ').downcase
81
+ end
82
+
83
+ # Sign headers
84
+ #
85
+ # @return [String]
86
+ def signed_headers
87
+ Base64.strict_encode64(
88
+ private_key.sign(
89
+ OpenSSL::Digest.new('SHA256'),
90
+ signature_content
91
+ )
92
+ )
93
+ end
94
+
95
+ # Generates a string to be signed
96
+ #
97
+ # @return [String]
98
+ def signature_content
99
+ slice(*SIGNABLE_HEADERS).map do |key, value|
100
+ "#{key.downcase}: #{value}"
101
+ end.join("\n")
102
+ end
103
+ end
104
+ end
105
+ end
106
+ end
@@ -3,5 +3,5 @@
3
3
  # API client
4
4
  class DistributedPress
5
5
  # Version
6
- VERSION = '0.3.1'
6
+ VERSION = '0.4.0'
7
7
  end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'dry/schema/processor'
4
+
5
+ module Dry
6
+ module Schema
7
+ # Include the processor as Result option so we can deserialize
8
+ # later.
9
+ module ProcessorDecorator
10
+ def self.included(base)
11
+ base.class_eval do
12
+ # Add the processor to the result so we can deserialize it
13
+ # later
14
+ def call(input)
15
+ Result.new(input.dup, message_compiler: message_compiler, processor: self) do |result|
16
+ steps.call(result)
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
24
+
25
+ Dry::Schema::Processor.include Dry::Schema::ProcessorDecorator
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'dry/schema/processor'
4
+
5
+ module Dry
6
+ module Schema
7
+ # This decorator allows to serialize Dry::Schema::Result objects
8
+ # by storing the data and processor so they can be run again during
9
+ # deserialization.
10
+ module ResultDecorator
11
+ def self.included(base)
12
+ base.class_eval do
13
+ option :processor
14
+
15
+ def _dump(_)
16
+ Marshal.dump(
17
+ {
18
+ processor: processor.class,
19
+ data: to_h
20
+ }
21
+ )
22
+ end
23
+
24
+ def self._load(data)
25
+ data = Marshal.load(data)
26
+
27
+ data[:processor].new.call(data[:data])
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
34
+
35
+ Dry::Schema::Result.include Dry::Schema::ResultDecorator
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: distributed-press-api-client
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.1
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - f
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2023-12-01 00:00:00.000000000 Z
11
+ date: 2024-03-11 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: addressable
@@ -72,6 +72,20 @@ dependencies:
72
72
  - - "~>"
73
73
  - !ruby/object:Gem::Version
74
74
  version: '0.18'
75
+ - !ruby/object:Gem::Dependency
76
+ name: httparty-cache
77
+ requirement: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - "~>"
80
+ - !ruby/object:Gem::Version
81
+ version: 0.0.6
82
+ type: :runtime
83
+ prerelease: false
84
+ version_requirements: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - "~>"
87
+ - !ruby/object:Gem::Version
88
+ version: 0.0.6
75
89
  - !ruby/object:Gem::Dependency
76
90
  name: json
77
91
  requirement: !ruby/object:Gem::Requirement
@@ -224,6 +238,20 @@ dependencies:
224
238
  - - ">="
225
239
  - !ruby/object:Gem::Version
226
240
  version: '0'
241
+ - !ruby/object:Gem::Dependency
242
+ name: ruby-brs
243
+ requirement: !ruby/object:Gem::Requirement
244
+ requirements:
245
+ - - ">="
246
+ - !ruby/object:Gem::Version
247
+ version: '0'
248
+ type: :development
249
+ prerelease: false
250
+ version_requirements: !ruby/object:Gem::Requirement
251
+ requirements:
252
+ - - ">="
253
+ - !ruby/object:Gem::Version
254
+ version: '0'
227
255
  - !ruby/object:Gem::Dependency
228
256
  name: webmock
229
257
  requirement: !ruby/object:Gem::Requirement
@@ -279,9 +307,21 @@ files:
279
307
  - lib/distributed_press/v1/schemas/token_payload.rb
280
308
  - lib/distributed_press/v1/schemas/update_site.rb
281
309
  - lib/distributed_press/v1/social.rb
310
+ - lib/distributed_press/v1/social/allowlist.rb
311
+ - lib/distributed_press/v1/social/blocklist.rb
282
312
  - lib/distributed_press/v1/social/client.rb
313
+ - lib/distributed_press/v1/social/dereferencer.rb
314
+ - lib/distributed_press/v1/social/hook.rb
315
+ - lib/distributed_press/v1/social/inbox.rb
316
+ - lib/distributed_press/v1/social/outbox.rb
317
+ - lib/distributed_press/v1/social/reference.rb
318
+ - lib/distributed_press/v1/social/referenced_object.rb
319
+ - lib/distributed_press/v1/social/schemas/webhook.rb
320
+ - lib/distributed_press/v1/social/signed_headers.rb
283
321
  - lib/distributed_press/v1/token.rb
284
322
  - lib/distributed_press/version.rb
323
+ - lib/dry/schema/processor_decorator.rb
324
+ - lib/dry/schema/result_decorator.rb
285
325
  - lib/jekyll-distributed-press-v0.rb
286
326
  homepage: https://0xacab.org/sutty/distributed-press-api-client
287
327
  licenses: