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.
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'addressable'
4
+ require_relative 'reference'
5
+
6
+ class DistributedPress
7
+ module V1
8
+ module Social
9
+ # Fetches ActivityStreams from different instances by
10
+ # instantiating clients.
11
+ class Dereferencer
12
+ # @return [DistributedPress::V1::Social::Client]
13
+ attr_reader :client
14
+
15
+ REFERENTIABLE_ATTRIBUTES =
16
+ %w[
17
+ actor
18
+ owner
19
+ attributedTo
20
+ cc
21
+ inReplyTo
22
+ object
23
+ replies
24
+ to
25
+ publicKey
26
+
27
+ alsoKnownAs
28
+ devices
29
+ featured
30
+ featuredTags
31
+ followers
32
+ following
33
+ inbox
34
+ movedTo
35
+ outbox
36
+
37
+ first
38
+ items
39
+ next
40
+ orderedItems
41
+ partOf
42
+ prev
43
+ ].freeze
44
+
45
+ # @param :client [DistributedPress::V1::Social::Client]
46
+ def initialize(client:)
47
+ @client = client
48
+ @parser =
49
+ proc do |body, format|
50
+ next HTTParty::Parser.call(body, format || :plain) unless body&.starts_with? '{'
51
+
52
+ HTTParty::Parser.call(body, :json).tap do |object|
53
+ reference_object! object
54
+ end
55
+ end
56
+ end
57
+
58
+ # Fetch a URI
59
+ #
60
+ # @param :uri [String, Addressable::URI]
61
+ # @return [HTTParty::Response]
62
+ def get(uri:)
63
+ uri = uris(uri)
64
+
65
+ clients(uri).get(endpoint: uri.path, parser: @parser)
66
+ end
67
+
68
+ # Gets a client for a URI
69
+ #
70
+ # @param :uri [Addressable::URI]
71
+ # @return [DistributedPress::V1::Social::Client]
72
+ def clients(uri)
73
+ @clients ||= {}
74
+ @clients[uri.origin] ||=
75
+ client.class.new(
76
+ url: uri.origin,
77
+ public_key_url: client.public_key_url,
78
+ private_key_pem: client.private_key.to_s,
79
+ logger: client.logger,
80
+ cache_store: client.class.cache_store
81
+ )
82
+ end
83
+
84
+ # Gets a reference for a URI and indexes it by the complete
85
+ # and normalized URI
86
+ #
87
+ # @param :uri [String, Addressable::URI]
88
+ # @return [DistributedPress::V1::Social::Reference]
89
+ def references(uri)
90
+ @references ||= {}
91
+ @references[uri.to_s] ||= Reference.new(uri: uri.to_s, dereferencer: self)
92
+ end
93
+
94
+ # Make sure we're getting a normalized Addressable::URI
95
+ #
96
+ # @param :uri [String, Addressable::URI]
97
+ # @return [Addressable::URI]
98
+ def uris(uri)
99
+ @uris ||= {}
100
+ @uris[uri.to_s] ||=
101
+ (if uri.is_a? Addressable::URI
102
+ uri
103
+ else
104
+ Addressable::URI.parse(uri)
105
+ end).normalize
106
+ end
107
+
108
+ def reference_object!(object)
109
+ REFERENTIABLE_ATTRIBUTES.each do |attribute|
110
+ next unless object.key? attribute
111
+
112
+ case object[attribute]
113
+ when Array
114
+ object[attribute].map! do |o|
115
+ case o
116
+ when Hash
117
+ reference_object!(o)
118
+ o
119
+ when String then references(uris(o))
120
+ end
121
+ end
122
+ when Hash
123
+ reference_object!(object[attribute])
124
+ when String
125
+ object[attribute] = references(uris(object[attribute]))
126
+ end
127
+ end
128
+ end
129
+ end
130
+ end
131
+ end
132
+ 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,66 @@
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
+ # Get the actor's inbox
25
+ #
26
+ # @return [HTTParty::Response]
27
+ def get
28
+ client.get(endpoint: endpoint)
29
+ end
30
+
31
+ # Send an activity to the actor's inbox. This is typically done
32
+ # by other actors though, not ourselves, so it could be used to
33
+ # send directly to another Actor's Social Inbox.
34
+ #
35
+ # @param :activity [Hash]
36
+ # @return [HTTParty::Response]
37
+ def post(activity:)
38
+ client.post(endpoint: endpoint, body: activity)
39
+ end
40
+
41
+ # Reject an activity queued on the inbox
42
+ #
43
+ # @param :id [String] Activity ID
44
+ # @return [HTTParty::Response]
45
+ def reject(id:)
46
+ client.delete(endpoint: "#{endpoint}/#{URI.encode_uri_component(id)}")
47
+ end
48
+
49
+ # Accept an activity queued on the inbox
50
+ #
51
+ # @param :id [String] Activity ID
52
+ # @return [HTTParty::Response]
53
+ def accept(id:)
54
+ client.post(endpoint: "#{endpoint}/#{URI.encode_uri_component(id)}", body: {})
55
+ end
56
+
57
+ # Inbox
58
+ #
59
+ # @return [String]
60
+ def endpoint
61
+ @endpoint ||= "/v1/#{actor}/inbox"
62
+ end
63
+ end
64
+ end
65
+ end
66
+ 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.0rc3'
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