distributed-press-api-client 0.3.0 → 0.4.0rc3

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.
@@ -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.0'
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