fedipub 0.0.1 → 0.9.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.
Files changed (132) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +70 -2
  3. data/Rakefile +5 -9
  4. data/app/assets/config/fedipub_manifest.js +1 -0
  5. data/app/assets/stylesheets/fedipub/application.css +15 -0
  6. data/app/controllers/concerns/fedipub/server/render_collections.rb +19 -0
  7. data/app/controllers/fedipub/client/activities_controller.rb +27 -0
  8. data/app/controllers/fedipub/client/actors_controller.rb +54 -0
  9. data/app/controllers/fedipub/client/followings_controller.rb +103 -0
  10. data/app/controllers/fedipub/client_controller.rb +9 -0
  11. data/app/controllers/fedipub/server/activities_controller.rb +78 -0
  12. data/app/controllers/fedipub/server/actors_controller.rb +50 -0
  13. data/app/controllers/fedipub/server/followings_controller.rb +19 -0
  14. data/app/controllers/fedipub/server/nodeinfo_controller.rb +28 -0
  15. data/app/controllers/fedipub/server/published_controller.rb +30 -0
  16. data/app/controllers/fedipub/server/web_finger_controller.rb +45 -0
  17. data/app/controllers/fedipub/server_controller.rb +37 -0
  18. data/app/helpers/fedipub/server_helper.rb +18 -0
  19. data/app/jobs/fedipub/application_job.rb +10 -0
  20. data/app/jobs/fedipub/fetch_nodeinfo_job.rb +10 -0
  21. data/app/jobs/fedipub/notify_inbox_job.rb +10 -0
  22. data/app/mailers/fedipub/application_mailer.rb +6 -0
  23. data/app/models/concerns/fedipub/actor_entity.rb +166 -0
  24. data/app/models/concerns/fedipub/announceable.rb +18 -0
  25. data/app/models/concerns/fedipub/data_entity.rb +279 -0
  26. data/app/models/concerns/fedipub/handles_delete_requests.rb +36 -0
  27. data/app/models/concerns/fedipub/has_uuid.rb +61 -0
  28. data/app/models/concerns/fedipub/likeable.rb +29 -0
  29. data/app/models/fedipub/activity.rb +62 -0
  30. data/app/models/fedipub/actor.rb +303 -0
  31. data/app/models/fedipub/application_record.rb +5 -0
  32. data/app/models/fedipub/following.rb +72 -0
  33. data/app/models/fedipub/host.rb +48 -0
  34. data/app/policies/fedipub/client/activity_policy.rb +9 -0
  35. data/app/policies/fedipub/client/actor_policy.rb +15 -0
  36. data/app/policies/fedipub/client/following_policy.rb +35 -0
  37. data/app/policies/fedipub/fedipub_policy.rb +63 -0
  38. data/app/policies/fedipub/server/activity_policy.rb +9 -0
  39. data/app/policies/fedipub/server/actor_policy.rb +23 -0
  40. data/app/policies/fedipub/server/following_policy.rb +6 -0
  41. data/app/policies/fedipub/server/publishable_policy.rb +15 -0
  42. data/app/views/fedipub/client/activities/_activity.html.erb +5 -0
  43. data/app/views/fedipub/client/activities/_activity.json.jbuilder +1 -0
  44. data/app/views/fedipub/client/activities/_index.json.jbuilder +1 -0
  45. data/app/views/fedipub/client/activities/feed.html.erb +11 -0
  46. data/app/views/fedipub/client/activities/feed.json.jbuilder +1 -0
  47. data/app/views/fedipub/client/activities/index.html.erb +5 -0
  48. data/app/views/fedipub/client/activities/index.json.jbuilder +1 -0
  49. data/app/views/fedipub/client/actors/_actor.json.jbuilder +17 -0
  50. data/app/views/fedipub/client/actors/_lookup_form.html.erb +5 -0
  51. data/app/views/fedipub/client/actors/gone.html.erb +1 -0
  52. data/app/views/fedipub/client/actors/index.html.erb +44 -0
  53. data/app/views/fedipub/client/actors/index.json.jbuilder +1 -0
  54. data/app/views/fedipub/client/actors/show.html.erb +112 -0
  55. data/app/views/fedipub/client/actors/show.json.jbuilder +1 -0
  56. data/app/views/fedipub/client/common/_client_links.html.erb +26 -0
  57. data/app/views/fedipub/client/followings/_follow.html.erb +4 -0
  58. data/app/views/fedipub/client/followings/_follow_actions.html.erb +36 -0
  59. data/app/views/fedipub/client/followings/_follower.html.erb +7 -0
  60. data/app/views/fedipub/client/followings/_following.json.jbuilder +1 -0
  61. data/app/views/fedipub/client/followings/_form.html.erb +22 -0
  62. data/app/views/fedipub/client/followings/index.html.erb +29 -0
  63. data/app/views/fedipub/client/followings/index.json.jbuilder +1 -0
  64. data/app/views/fedipub/client/followings/show.html.erb +21 -0
  65. data/app/views/fedipub/client/followings/show.json.jbuilder +1 -0
  66. data/app/views/fedipub/server/activities/_activity.activitypub.jbuilder +25 -0
  67. data/app/views/fedipub/server/activities/show.activitypub.jbuilder +1 -0
  68. data/app/views/fedipub/server/actors/_actor.activitypub.jbuilder +27 -0
  69. data/app/views/fedipub/server/actors/_tombstone.activitypub.jbuilder +6 -0
  70. data/app/views/fedipub/server/actors/show.activitypub.jbuilder +5 -0
  71. data/app/views/fedipub/server/followings/_following.activitypub.jbuilder +7 -0
  72. data/app/views/fedipub/server/followings/show.activitypub.jbuilder +1 -0
  73. data/app/views/fedipub/server/nodeinfo/index.nodeinfo.jbuilder +6 -0
  74. data/app/views/fedipub/server/nodeinfo/show.nodeinfo.jbuilder +19 -0
  75. data/app/views/fedipub/server/published/_publishable.activitypub.jbuilder +11 -0
  76. data/app/views/fedipub/server/published/_tombstone.activitypub.jbuilder +6 -0
  77. data/app/views/fedipub/server/published/show.activitypub.jbuilder +5 -0
  78. data/app/views/fedipub/server/shared/ordered_collection.activitypub.jbuilder +6 -0
  79. data/app/views/fedipub/server/shared/ordered_collection_page.activitypub.jbuilder +12 -0
  80. data/app/views/fedipub/server/web_finger/find.jrd.jbuilder +24 -0
  81. data/app/views/fedipub/server/web_finger/host_meta.xrd.erb +5 -0
  82. data/app/views/users/show.html.erb +4 -0
  83. data/config/initializers/mime_types.rb +21 -0
  84. data/config/initializers/security_vocab.rb +53 -0
  85. data/config/routes.rb +53 -0
  86. data/db/migrate/20200712133150_create_federails_actors.rb +22 -0
  87. data/db/migrate/20200712143127_create_federails_followings.rb +14 -0
  88. data/db/migrate/20200712174938_create_federails_activities.rb +11 -0
  89. data/db/migrate/20241002094500_add_uuids.rb +13 -0
  90. data/db/migrate/20241002094501_add_keypair_to_actors.rb +8 -0
  91. data/db/migrate/20250122160618_add_extensions_to_federails_actors.rb +5 -0
  92. data/db/migrate/20250301082500_add_local_to_actors.rb +11 -0
  93. data/db/migrate/20250329123939_add_actor_type_to_actors.rb +5 -0
  94. data/db/migrate/20250329123940_add_tombstoned_at_to_actors.rb +5 -0
  95. data/db/migrate/20250426061729_create_federails_hosts.rb +22 -0
  96. data/db/migrate/20251121160720_add_to_and_cc_to_federails_activities.rb +6 -0
  97. data/db/migrate/20260421170640_add_result_and_instrument_to_federails_activities.rb +6 -0
  98. data/db/migrate/20260723113716_rename_federails_to_fedipub.rb +15 -0
  99. data/lib/fedipub/configuration.rb +119 -0
  100. data/lib/fedipub/data_transformer/note.rb +36 -0
  101. data/lib/fedipub/engine.rb +11 -0
  102. data/lib/fedipub/maintenance/actors_updater.rb +66 -0
  103. data/lib/fedipub/maintenance/hosts_updater.rb +19 -0
  104. data/lib/fedipub/utils/actor.rb +85 -0
  105. data/lib/fedipub/utils/context.rb +12 -0
  106. data/lib/fedipub/utils/host.rb +54 -0
  107. data/lib/fedipub/utils/json_request.rb +41 -0
  108. data/lib/fedipub/utils/object.rb +131 -0
  109. data/lib/fedipub/utils/response_codes.rb +11 -0
  110. data/lib/fedipub/version.rb +1 -3
  111. data/lib/fedipub.rb +127 -4
  112. data/lib/fediverse/collection.rb +31 -0
  113. data/lib/fediverse/inbox.rb +110 -0
  114. data/lib/fediverse/node_info.rb +38 -0
  115. data/lib/fediverse/notifier.rb +90 -0
  116. data/lib/fediverse/request.rb +29 -0
  117. data/lib/fediverse/signature.rb +49 -0
  118. data/lib/fediverse/webfinger.rb +140 -0
  119. data/lib/fediverse.rb +5 -0
  120. data/lib/generators/fedipub/copy_client_policies/USAGE +8 -0
  121. data/lib/generators/fedipub/copy_client_policies/copy_client_policies_generator.rb +9 -0
  122. data/lib/generators/fedipub/copy_client_views/USAGE +8 -0
  123. data/lib/generators/fedipub/copy_client_views/copy_client_views_generator.rb +9 -0
  124. data/lib/generators/fedipub/copy_factories/USAGE +8 -0
  125. data/lib/generators/fedipub/copy_factories/copy_factories_generator.rb +43 -0
  126. data/lib/generators/fedipub/install/USAGE +9 -0
  127. data/lib/generators/fedipub/install/install_generator.rb +10 -0
  128. data/lib/generators/fedipub/install/templates/fedipub.rb +1 -0
  129. data/lib/generators/fedipub/install/templates/fedipub.yml +27 -0
  130. data/lib/tasks/factory_bot.rake +15 -0
  131. data/lib/tasks/fedipub_tasks.rake +13 -0
  132. metadata +259 -10
@@ -0,0 +1,131 @@
1
+ module Fedipub
2
+ module Utils
3
+ # Methods to manipulate incoming objects
4
+ class Object
5
+ class << self
6
+ # Finds data from an object or its ID.
7
+ #
8
+ # When data exists locally, the entity is returned.
9
+ # For distant data, a new instance is returned unless the target does not exist.
10
+ #
11
+ # @param object_or_id [String, Hash] String identifier or incoming object
12
+ #
13
+ # @return [ApplicationRecord, nil] Entity or nil when invalid/not found
14
+ def find_or_initialize(object_or_id)
15
+ federated_url = object_or_id.is_a?(Hash) ? object_or_id['id'] : object_or_id
16
+
17
+ route = local_route(federated_url)
18
+ return from_local_route(route) if route
19
+
20
+ from_distant_server(object_or_id)
21
+ end
22
+
23
+ # Search for a distant object in actors and configured data entities.
24
+ #
25
+ # This is useful to find something when the type is unknown, as an object from a Delete activity.
26
+ #
27
+ # @param federated_url [String] Object identifier
28
+ # @return [Fedipub::Actor, Fedipub::DataEntity, Fedipub::Following, nil]
29
+ def find_distant_object_in_all(federated_url)
30
+ # Search in actors
31
+ object = Fedipub::Actor.find_by federated_url: federated_url
32
+ return object if object.present?
33
+
34
+ # Search in followings
35
+ object = Fedipub::Following.find_by federated_url: federated_url
36
+ return object if object.present?
37
+
38
+ # Search in data entities
39
+ Fedipub.configuration.data_types.keys.sort.each do |klass|
40
+ object = klass.constantize.find_by federated_url: federated_url
41
+
42
+ break if object.present?
43
+ end
44
+
45
+ object
46
+ end
47
+
48
+ # Finds or initializes an entity from an ActivityPub object or id
49
+ #
50
+ # @see .find_or_initialize
51
+ #
52
+ # @param object_or_id [String, Hash] String identifier or incoming object
53
+ #
54
+ # @return [ApplicationRecord, nil] Entity or nil when invalid/not found
55
+ def find_or_initialize!(object_or_id)
56
+ entity = find_or_initialize object_or_id
57
+ raise ActiveRecord::RecordNotFound unless entity
58
+
59
+ entity
60
+ end
61
+
62
+ # Finds or create an entity from an ActivityPub object or id
63
+ #
64
+ # Note that the data transformer MUST return timestamps from the ActivityPub object if used on the model,
65
+ # as they won't be set automatically.
66
+ #
67
+ # @see .find_or_initialize!
68
+ #
69
+ # @param object_or_id [String, Hash] String identifier or incoming object
70
+ #
71
+ # @return [ApplicationRecord, nil] Entity or nil when invalid/not found
72
+ def find_or_create!(object_or_id)
73
+ entity = find_or_initialize! object_or_id
74
+ return entity if entity.persisted?
75
+
76
+ entity.save!(touch: false)
77
+ entity
78
+ end
79
+
80
+ # Returns the timestamps to use from an ActivityPub object
81
+ #
82
+ # @param hash [Hash] ActivityPub object
83
+ #
84
+ # @return [Hash] Hash with timestamps
85
+ def timestamp_attributes(hash)
86
+ {
87
+ created_at: hash['published'] ||= Time.current,
88
+ updated_at: hash['updated'].presence || hash['published'],
89
+ }
90
+ end
91
+
92
+ private
93
+
94
+ def local_route(url)
95
+ route = Utils::Host.local_route(url)
96
+
97
+ return nil unless route && route[:controller] == 'fedipub/server/published' && route[:action] == 'show'
98
+
99
+ route
100
+ end
101
+
102
+ def from_local_route(route)
103
+ config = Fedipub.data_entity_handled_on route[:publishable_type]
104
+ return unless config
105
+
106
+ config[:class]&.find_by(config[:url_param] => route[:id])
107
+ rescue ActiveRecord::RecordNotFound
108
+ nil
109
+ end
110
+
111
+ def from_distant_server(federated_url)
112
+ hash = Fediverse::Request.dereference(federated_url)
113
+ return unless hash
114
+
115
+ handler = Fedipub.data_entity_handler_for hash
116
+ return unless handler
117
+
118
+ entity = handler[:class].find_by federated_url: hash['id']
119
+ return entity if entity
120
+
121
+ entity = handler[:class].new_from_activitypub_object(hash)
122
+ return unless entity
123
+
124
+ entity.fedipub_actor = Fedipub::Actor.find_by_federation_url hash['attributedTo'] if entity && !entity.fedipub_actor
125
+
126
+ entity
127
+ end
128
+ end
129
+ end
130
+ end
131
+ end
@@ -0,0 +1,11 @@
1
+ module Fedipub
2
+ module Utils
3
+ module ResponseCodes
4
+ UNPROCESSABLE_CONTENT = if Gem::Version.new(Rack::RELEASE) < Gem::Version.new('3.1')
5
+ :unprocessable_entity
6
+ else
7
+ :unprocessable_content
8
+ end
9
+ end
10
+ end
11
+ end
@@ -1,5 +1,3 @@
1
- # frozen_string_literal: true
2
-
3
1
  module Fedipub
4
- VERSION = "0.0.1"
2
+ VERSION = '0.9.0'.freeze
5
3
  end
data/lib/fedipub.rb CHANGED
@@ -1,8 +1,131 @@
1
- # frozen_string_literal: true
1
+ require 'jbuilder'
2
+ require 'kaminari'
3
+ require 'pundit'
2
4
 
3
- require_relative "fedipub/version"
5
+ require 'fedipub/version'
6
+ require 'fedipub/engine'
7
+ require 'fedipub/configuration'
8
+ require 'fedipub/utils/object'
9
+ require 'fedipub/utils/json_request'
10
+ require 'fedipub/utils/response_codes'
4
11
 
12
+ require 'fediverse'
13
+
14
+ # rubocop:disable Style/ClassVars
15
+
16
+ # This module includes classes and methods related to Ruby on Rails: engine configuration, models, controllers, etc.
5
17
  module Fedipub
6
- class Error < StandardError; end
7
- # Your code goes here...
18
+ DEFAULT_DATA_FILTER_METHOD = :handle_federated_object?
19
+
20
+ mattr_reader :configuration
21
+ @@configuration = Configuration
22
+
23
+ class << self
24
+ def configure
25
+ yield @@configuration
26
+ end
27
+
28
+ def config_from(name) # rubocop:disable Metrics/MethodLength
29
+ config = Rails.application.config_for name
30
+ [
31
+ :app_name,
32
+ :app_version,
33
+ :force_ssl,
34
+ :site_host,
35
+ :site_port,
36
+ :enable_discovery,
37
+ :open_registrations,
38
+ :nodeinfo_metadata,
39
+ :app_layout,
40
+ :server_routes_path,
41
+ :client_routes_path,
42
+ :remote_follow_url_method,
43
+ :base_client_controller,
44
+ :remote_entities_cache_duration,
45
+ :job_queue,
46
+ ].each { |key| Configuration.send :"#{key}=", config[key] if config.key?(key) }
47
+ end
48
+
49
+ # @return [Boolean] True if the given model is a possible actor
50
+ #
51
+ # @example
52
+ # puts "Follow #{some_actor.name}" if actor_entity? current_user
53
+ def actor_entity?(class_or_instance)
54
+ Configuration.actor_types.key? class_or_instance_name(class_or_instance)
55
+ end
56
+
57
+ # @return [Hash] The configuration for the given actor entity
58
+ def actor_entity(class_or_instance)
59
+ klass = class_or_instance_name(class_or_instance)
60
+ raise "#{klass} is not a configured actor entity" unless Configuration.actor_types.key?(klass)
61
+
62
+ Configuration.actor_types[klass]
63
+ end
64
+
65
+ # @return [Boolean] True if the given model is a possible data entity
66
+ def data_entity?(class_or_instance)
67
+ Configuration.data_types.key? class_or_instance_name(class_or_instance)
68
+ end
69
+
70
+ # Finds configured data types from ActivityPub type
71
+ #
72
+ # @param type [String] ActivityPub object type, as configured with `:handles`
73
+ # @return [Array] List of data entity configurations
74
+ #
75
+ # @example
76
+ # data_entity_handlers_for 'Note'
77
+ def data_entity_handlers_for(type)
78
+ Fedipub::Configuration.data_types.select { |_, v| v[:handles] == type }.map(&:last)
79
+ end
80
+
81
+ # Finds the configured handler for a given ActivityPub object
82
+ #
83
+ # @param hash [Hash] ActivityPub object hash
84
+ #
85
+ # @return [Hash, nil] Data entity configuration
86
+ def data_entity_handler_for(hash)
87
+ data_entity_handlers_for(hash['type']).find do |handler|
88
+ if handler[:filter_method] || handler[:class].respond_to?(DEFAULT_DATA_FILTER_METHOD)
89
+ handler[:class].send(handler[:filter_method] || DEFAULT_DATA_FILTER_METHOD, hash)
90
+ else
91
+ true
92
+ end
93
+ end
94
+ end
95
+
96
+ # Finds configured data type from route path segment
97
+ #
98
+ # @param route_path_segment [Symbol, String] Route path segment, as configured with `:route_path_segment`
99
+ # @return [Hash, nil] Entity configuration
100
+ #
101
+ # @example
102
+ # data_entity_handled_on :articles
103
+ def data_entity_handled_on(route_path_segment)
104
+ route_path_segment = route_path_segment.to_sym
105
+ Fedipub::Configuration.data_types.find { |_, v| v[:route_path_segment] == route_path_segment }&.last
106
+ end
107
+
108
+ # @return [Hash] The configuration for the given data entity
109
+ def data_entity_configuration(class_or_instance)
110
+ klass = class_or_instance_name(class_or_instance)
111
+ raise "#{klass} is not a configured data entity" unless Configuration.data_types.key?(klass)
112
+
113
+ Configuration.data_types[klass]
114
+ end
115
+
116
+ private
117
+
118
+ # @return [String] Class name of the provided class or instance
119
+ def class_or_instance_name(class_or_instance)
120
+ case class_or_instance
121
+ when String
122
+ class_or_instance
123
+ when Class
124
+ class_or_instance.name
125
+ else
126
+ class_or_instance.class.name
127
+ end
128
+ end
129
+ end
8
130
  end
131
+ # rubocop:enable Style/ClassVars
@@ -0,0 +1,31 @@
1
+ module Fediverse
2
+ class Collection < Array
3
+ PUBLIC = 'https://www.w3.org/ns/activitystreams#Public'.freeze
4
+
5
+ attr_reader :total_items, :id, :type
6
+
7
+ def self.fetch(url)
8
+ new.fetch(url)
9
+ end
10
+
11
+ def fetch(url)
12
+ json = Fediverse::Request.dereference(url)
13
+ @total_items = json['totalItems']
14
+ @id = json['id']
15
+ @type = json['type']
16
+ raise Errors::NotACollection unless %w[OrderedCollection Collection].include?(@type)
17
+
18
+ next_url = json['first']
19
+ while next_url
20
+ page = Fediverse::Request.dereference(next_url)
21
+ concat(page['orderedItems'] || page['items'])
22
+ next_url = page['next']
23
+ end
24
+ self
25
+ end
26
+ end
27
+
28
+ module Errors
29
+ class NotACollection < StandardError; end
30
+ end
31
+ end
@@ -0,0 +1,110 @@
1
+ require 'fediverse/request'
2
+
3
+ module Fediverse
4
+ class Inbox
5
+ @@handlers = {} # rubocop:todo Style/ClassVars
6
+ class << self
7
+ # Registers a handler for incoming data
8
+ #
9
+ # Unless a specific type is not implemented in Fedipub, you should leave the 'Delete' activity to Fedipub:
10
+ # it will dispatch a `on_fedipub_delete_requested` event on the right objects.
11
+ #
12
+ # @param activity_type [String] Target activity type ('Create', 'Follow', 'Like', ...)
13
+ # See https://www.w3.org/TR/activitystreams-vocabulary/#activity-types for a list of common ones
14
+ # @param object_type [String] Type of the related object ('Article', 'Note', ...)
15
+ # See https://www.w3.org/TR/activitystreams-vocabulary/#object-types for a list of common object types
16
+ # @param klass [String] Class handling the incoming object
17
+ # @param method [Symbol] Method in the class that will handle the object
18
+ def register_handler(activity_type, object_type, klass, method)
19
+ @@handlers[activity_type] ||= {}
20
+ @@handlers[activity_type][object_type] ||= {}
21
+ @@handlers[activity_type][object_type][klass] = method
22
+ end
23
+
24
+ # Executes the registered handler for an incoming object
25
+ #
26
+ # @param payload [Hash] Dereferenced activity
27
+ def dispatch_request(payload)
28
+ return dispatch_delete_request(payload) if payload['type'] == 'Delete'
29
+
30
+ payload['object'] = Fediverse::Request.dereference(payload['object']) if payload.key? 'object'
31
+
32
+ handlers = get_handlers(payload['type'], payload.dig('object', 'type'))
33
+ handlers.each_pair do |klass, method|
34
+ klass.send method, payload
35
+ end
36
+ return true unless handlers.empty?
37
+
38
+ Rails.logger.debug { "Unhandled activity type: #{payload['type']}" }
39
+ false
40
+ end
41
+
42
+ private
43
+
44
+ def dispatch_delete_request(payload)
45
+ payload['object'] = payload['object']['id'] unless payload['object'].is_a? String
46
+ object = Fedipub::Utils::Object.find_distant_object_in_all payload['object']
47
+ return if object.blank?
48
+
49
+ object.run_callbacks :on_fedipub_delete_requested
50
+ end
51
+
52
+ def get_handlers(activity_type, object_type)
53
+ {}.merge(@@handlers.dig(activity_type, object_type) || {})
54
+ .merge(@@handlers.dig(activity_type, '*') || {})
55
+ .merge(@@handlers.dig('*', '*') || {})
56
+ .merge(@@handlers.dig('*', object_type) || {})
57
+ end
58
+
59
+ def handle_create_follow_request(activity)
60
+ actor = Fedipub::Actor.find_or_create_by_object activity['actor']
61
+ target_actor = Fedipub::Actor.find_or_create_by_object activity['object']
62
+
63
+ Fedipub::Following.create! actor: actor, target_actor: target_actor, federated_url: activity['id']
64
+ end
65
+
66
+ def handle_accept_follow_request(activity)
67
+ original_activity = Request.dereference(activity['object'])
68
+
69
+ actor = Fedipub::Actor.find_or_create_by_object original_activity['actor']
70
+ target_actor = Fedipub::Actor.find_or_create_by_object original_activity['object']
71
+ raise 'Follow not accepted by target actor but by someone else' if activity['actor'] != target_actor.federated_url
72
+
73
+ follow = Fedipub::Following.find_by actor: actor, target_actor: target_actor
74
+ follow.accept!
75
+ end
76
+
77
+ def handle_undo_follow_request(activity)
78
+ original_activity = activity['object']
79
+
80
+ actor = Fedipub::Actor.find_or_create_by_object original_activity['actor']
81
+ target_actor = Fedipub::Actor.find_or_create_by_object original_activity['object']
82
+
83
+ follow = Fedipub::Following.find_by actor: actor, target_actor: target_actor
84
+ follow&.destroy
85
+ end
86
+
87
+ def handle_delete_request(activity)
88
+ object = Fedipub::Utils::Object.find_distant_object_in_all(activity['object'])
89
+ return if object.blank?
90
+
91
+ object.run_callbacks :on_fedipub_delete_requested
92
+ end
93
+
94
+ def handle_undelete_request(activity)
95
+ # Get to original object
96
+ delete_activity = Request.dereference(activity['object'])
97
+ object = Fedipub::Utils::Object.find_distant_object_in_all(delete_activity['object'])
98
+ return if object.blank?
99
+
100
+ object.run_callbacks :on_fedipub_undelete_requested
101
+ end
102
+ end
103
+
104
+ register_handler 'Follow', '*', self, :handle_create_follow_request
105
+ register_handler 'Accept', 'Follow', self, :handle_accept_follow_request
106
+ register_handler 'Undo', 'Follow', self, :handle_undo_follow_request
107
+ register_handler 'Delete', '*', self, :handle_delete_request
108
+ register_handler 'Undo', 'Delete', self, :handle_undelete_request
109
+ end
110
+ end
@@ -0,0 +1,38 @@
1
+ module Fediverse
2
+ class NodeInfo
3
+ class NotFoundError < StandardError; end
4
+ class NoActivityPubError < StandardError; end
5
+
6
+ class << self
7
+ def fetch(domain)
8
+ url = nodeinfo_url(domain)
9
+
10
+ hash = Fedipub::Utils::JsonRequest.get_json url
11
+ raise NoActivityPubError, "#{domain} does not handle activitypub protocol" unless hash['protocols'].include? 'activitypub'
12
+
13
+ {
14
+ domain: domain,
15
+ nodeinfo_url: url,
16
+ software_version: hash.dig('software', 'version'),
17
+ software_name: hash.dig('software', 'name'),
18
+ protocols: hash['protocols'],
19
+ services: hash['services'],
20
+ }
21
+ end
22
+
23
+ private
24
+
25
+ def base_url(domain)
26
+ scheme = Fedipub::Configuration.force_ssl ? 'https' : 'http'
27
+ @base_url = "#{scheme}://#{domain}"
28
+ end
29
+
30
+ def nodeinfo_url(domain)
31
+ response = Fedipub::Utils::JsonRequest.get_json "#{base_url(domain)}/.well-known/nodeinfo", follow_redirects: true
32
+ entry = response['links']&.find { |link| link['rel'] == 'http://nodeinfo.diaspora.software/ns/schema/2.0' }
33
+
34
+ entry['href']
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,90 @@
1
+ require 'fediverse/signature'
2
+
3
+ module Fediverse
4
+ class Notifier
5
+ class << self
6
+ # Posts an activity to its recipients
7
+ #
8
+ # @param activity [Fedipub::Activity]
9
+ def post_to_inboxes(activity)
10
+ # Get the list of actors we need to send the activity to
11
+ inboxes = inboxes_for(activity)
12
+ Rails.logger.debug('Nobody to notice') && return if inboxes.none?
13
+
14
+ # Deliver to each inbox
15
+ message = payload(activity)
16
+ inboxes.each do |url|
17
+ Rails.logger.debug { "Sending activity ##{activity.id} to inbox at #{url}" }
18
+ post_to_inbox(inbox_url: url, message: message, from: activity.actor)
19
+ end
20
+ end
21
+
22
+ private
23
+
24
+ # Determines the list of inboxes that the activity should be delivered to
25
+ #
26
+ # @return [Array<Fedipub::Actor>]
27
+ def inboxes_for(activity)
28
+ return [] unless activity.actor.local?
29
+
30
+ [activity.to, activity.cc].flatten.compact.reject { |x| x == Fediverse::Collection::PUBLIC }.map do |url|
31
+ actor = Fedipub::Actor.find_or_create_by_federation_url(url)
32
+ [actor.inbox_url]
33
+ rescue ActiveRecord::RecordNotFound, ActiveRecord::RecordInvalid
34
+ collection_to_actors(url).map(&:inbox_url)
35
+ end.flatten.compact
36
+ end
37
+
38
+ def collection_to_actors(url)
39
+ collection = Collection.fetch(url)
40
+ collection.filter_map do |actor_url|
41
+ Fedipub::Actor.find_or_create_by_federation_url(actor_url)
42
+ rescue ActiveRecord::RecordNotFound, ActiveRecord::RecordInvalid
43
+ nil
44
+ end
45
+ rescue Errors::NotACollection
46
+ []
47
+ end
48
+
49
+ def payload(activity)
50
+ Fedipub::ServerController.renderer.new.render(
51
+ template: 'fedipub/server/activities/show',
52
+ assigns: { activity: activity },
53
+ format: :json
54
+ )
55
+ end
56
+
57
+ def post_to_inbox(inbox_url:, message:, from: nil)
58
+ conn = Faraday.default_connection
59
+ conn.builder.build_response(
60
+ conn,
61
+ signed_request(url: inbox_url, message: message, from: from)
62
+ )
63
+ end
64
+
65
+ def signed_request(url:, message:, from:)
66
+ req = request(url: url, message: message)
67
+ req.headers['Signature'] = Fediverse::Signature.sign(sender: from, request: req) if from
68
+ req
69
+ end
70
+
71
+ def request(url:, message:) # rubocop:todo Metrics/AbcSize
72
+ Faraday.default_connection.build_request(:post) do |req|
73
+ req.url url
74
+ req.body = message
75
+ req.headers['Content-Type'] = Mime[:activitypub].to_s
76
+ req.headers['Accept'] = Mime[:activitypub].to_s
77
+ req.headers['Host'] = URI.parse(url).host
78
+ req.headers['Date'] = Time.now.utc.httpdate
79
+ req.headers['Digest'] = digest(message)
80
+ end
81
+ end
82
+
83
+ def digest(message)
84
+ "SHA-256=#{Base64.strict_encode64(
85
+ OpenSSL::Digest.new('SHA256').digest(message)
86
+ )}"
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,29 @@
1
+ require 'json/ld'
2
+
3
+ module Fediverse
4
+ class Request
5
+ class << self
6
+ # Dereferences a value
7
+ #
8
+ # @param value [String, Hash]
9
+ #
10
+ # @return [Hash, nil]
11
+ def dereference(value)
12
+ return value if value.is_a? Hash
13
+ return get(value) if value.is_a? String
14
+
15
+ raise "Unhandled object type #{value.class}"
16
+ end
17
+
18
+ private
19
+
20
+ def get(id)
21
+ json = Fedipub::Utils::JsonRequest.get_json(id)
22
+
23
+ JSON::LD::API.compact json, json['@context']
24
+ rescue JSON::ParserError
25
+ nil
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,49 @@
1
+ module Fediverse
2
+ class Signature
3
+ class << self
4
+ def sign(sender:, request:)
5
+ private_key = OpenSSL::PKey::RSA.new sender.private_key, Rails.application.credentials.secret_key_base
6
+ headers = '(request-target) host date digest'
7
+ sig = Base64.strict_encode64(
8
+ private_key.sign(
9
+ OpenSSL::Digest.new('SHA256'), signature_payload(request: request, headers: headers)
10
+ )
11
+ )
12
+ {
13
+ keyId: sender.key_id,
14
+ headers: headers,
15
+ signature: sig,
16
+ }.map { |k, v| "#{k}=\"#{v}\"" }.join(',')
17
+ end
18
+
19
+ def verify(sender:, request:)
20
+ raise 'Unsigned headers' unless request.headers['Signature']
21
+
22
+ signature_header = request.headers['Signature'].split(',').to_h do |pair|
23
+ /\A(?<key>\w+)="(?<value>.*)"\z/ =~ pair
24
+ [key, value]
25
+ end
26
+
27
+ headers = signature_header['headers']
28
+ signature = Base64.decode64(signature_header['signature'])
29
+ key = OpenSSL::PKey::RSA.new(sender.public_key)
30
+
31
+ comparison_string = signature_payload(request: request, headers: headers)
32
+
33
+ key.verify(OpenSSL::Digest.new('SHA256'), signature, comparison_string)
34
+ end
35
+
36
+ private
37
+
38
+ def signature_payload(request:, headers:)
39
+ headers.split.map do |signed_header_name|
40
+ if signed_header_name == '(request-target)'
41
+ "(request-target): #{request.http_method} #{URI.parse(request.path).path}"
42
+ else
43
+ "#{signed_header_name}: #{request.headers[signed_header_name.capitalize]}"
44
+ end
45
+ end.join("\n")
46
+ end
47
+ end
48
+ end
49
+ end