distributed-press-api-client 0.3.1 → 0.4.0

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: 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: