federails 0.7.0 → 0.8.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 (44) hide show
  1. checksums.yaml +4 -4
  2. data/app/controllers/concerns/federails/server/render_collections.rb +19 -0
  3. data/app/controllers/federails/client/followings_controller.rb +3 -3
  4. data/app/controllers/federails/server/activities_controller.rb +14 -6
  5. data/app/controllers/federails/server/actors_controller.rb +16 -7
  6. data/app/helpers/federails/server_helper.rb +6 -0
  7. data/app/jobs/federails/application_job.rb +2 -0
  8. data/app/jobs/federails/fetch_nodeinfo_job.rb +10 -0
  9. data/app/jobs/federails/notify_inbox_job.rb +0 -2
  10. data/app/models/concerns/federails/actor_entity.rb +2 -2
  11. data/app/models/federails/activity.rb +15 -22
  12. data/app/models/federails/actor.rb +8 -2
  13. data/app/models/federails/following.rb +3 -3
  14. data/app/models/federails/host.rb +48 -0
  15. data/app/views/federails/server/activities/_activity.activitypub.jbuilder +7 -3
  16. data/app/views/federails/server/actors/_actor.activitypub.jbuilder +7 -6
  17. data/app/views/federails/server/actors/_tombstone.activitypub.jbuilder +1 -4
  18. data/app/views/federails/server/followings/_following.activitypub.jbuilder +1 -1
  19. data/app/views/federails/server/nodeinfo/show.nodeinfo.jbuilder +2 -3
  20. data/app/views/federails/server/published/_publishable.activitypub.jbuilder +2 -2
  21. data/app/views/federails/server/published/_tombstone.activitypub.jbuilder +1 -4
  22. data/app/views/federails/server/shared/ordered_collection.activitypub.jbuilder +6 -0
  23. data/app/views/federails/server/shared/ordered_collection_page.activitypub.jbuilder +12 -0
  24. data/app/views/users/show.html.erb +4 -0
  25. data/db/migrate/20250426061729_create_federails_hosts.rb +22 -0
  26. data/db/migrate/20251121160720_add_to_and_cc_to_federails_activities.rb +6 -0
  27. data/lib/federails/configuration.rb +18 -0
  28. data/lib/federails/data_transformer/note.rb +6 -1
  29. data/lib/federails/maintenance/hosts_updater.rb +19 -0
  30. data/lib/federails/utils/context.rb +12 -0
  31. data/lib/federails/utils/response_codes.rb +11 -0
  32. data/lib/federails/version.rb +1 -1
  33. data/lib/federails.rb +11 -3
  34. data/lib/fediverse/collection.rb +31 -0
  35. data/lib/fediverse/notifier.rb +39 -12
  36. data/lib/fediverse/signature.rb +1 -1
  37. data/lib/fediverse.rb +2 -0
  38. data/lib/generators/federails/copy_factories/copy_factories_generator.rb +25 -3
  39. data/lib/generators/federails/install/templates/federails.yml +2 -0
  40. data/lib/tasks/federails_tasks.rake +5 -0
  41. metadata +18 -6
  42. data/app/views/federails/server/activities/outbox.activitypub.jbuilder +0 -18
  43. data/app/views/federails/server/actors/followers.activitypub.jbuilder +0 -18
  44. data/app/views/federails/server/actors/following.activitypub.jbuilder +0 -18
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 07b0025f15cc58379219f20956c53c0c3e6810e68ac60367b9728c9eff03220b
4
- data.tar.gz: 22957563450ab52509a350e6e50f397cb564396c0927c510439ea1ad041106d9
3
+ metadata.gz: 36117bc1ea548be65aebe4ee505850ecac3233851f07aaae18136188ec1c1077
4
+ data.tar.gz: '0958b68fce09e2775504b28f5227e098470c0cb3c89eb4ca3dec7f5ece871d78'
5
5
  SHA512:
6
- metadata.gz: 72e09779f3cdd23101c29e659f568f5dfddb5c85453c3853d49d1d7408b8017ea6c836b1b06afd6e3d62a6a476b8ce6746ec78b10e7f0d06ab32e825b947c258
7
- data.tar.gz: 2b6bd12fb24cb50925e637c10f5f16453afa799a438695e36a77c3478c7394d3b3204ca8f6cbcddad49dfa1a031dd5a3ffaefb42c100b946f5c6ca8ae4550128
6
+ metadata.gz: 2f6e4dc1e3554b81a02710b9d893c88ffa77ff7c33e623574378cd7b87a13165297dcd4b22a4ce38e90073a545708d7d82e17e9867b1f345a1ebcad2c1cdf237
7
+ data.tar.gz: a8fb6f9811a9369ee7cf706c64517f47c8a66b335de715b8325e508ca6790dc4e1f7db6ae67b62651920cf3fd467d1c2e23263fb5b4f38f2d40a2c1d5a9cdfc2
@@ -0,0 +1,19 @@
1
+ module Federails
2
+ module Server
3
+ module RenderCollections
4
+ extend ActiveSupport::Concern
5
+
6
+ def render_collection(actor:, collection:, url_helper:, &items_block)
7
+ if params[:page].present?
8
+ render_collection_page(actor: actor, collection: collection, url_helper: url_helper, items_block: items_block)
9
+ else
10
+ render 'federails/server/shared/ordered_collection', locals: { collection: collection, url_helper: url_helper, actor: actor }
11
+ end
12
+ end
13
+
14
+ def render_collection_page(collection:, actor:, url_helper:, items_block:)
15
+ render 'federails/server/shared/ordered_collection_page', locals: { collection: collection, url_helper: url_helper, actor: actor, items_block: items_block }
16
+ end
17
+ end
18
+ end
19
+ end
@@ -23,7 +23,7 @@ module Federails
23
23
  format.json { render :show, status: :ok, location: @following }
24
24
  else
25
25
  format.html { redirect_to url, alert: I18n.t('controller.followings.accept.error') }
26
- format.json { render json: @following.errors, status: :unprocessable_entity }
26
+ format.json { render json: @following.errors, status: Federails::Utils::ResponseCodes::UNPROCESSABLE_CONTENT }
27
27
  end
28
28
  end
29
29
  end
@@ -49,7 +49,7 @@ module Federails
49
49
  # Renders a 422 instead of a 404
50
50
  respond_to do |format|
51
51
  format.html { redirect_to federails.client_actors_url, alert: I18n.t('controller.followings.follow.error') }
52
- format.json { render json: { target_actor: ['does not exist'] }, status: :unprocessable_entity }
52
+ format.json { render json: { target_actor: ['does not exist'] }, status: Federails::Utils::ResponseCodes::UNPROCESSABLE_CONTENT }
53
53
  end
54
54
 
55
55
  return
@@ -94,7 +94,7 @@ module Federails
94
94
  format.json { render :show, status: :created, location: @following }
95
95
  else
96
96
  format.html { redirect_to url, alert: I18n.t('controller.followings.save_and_render.error') }
97
- format.json { render json: @following.errors, status: :unprocessable_entity }
97
+ format.json { render json: @following.errors, status: Federails::Utils::ResponseCodes::UNPROCESSABLE_CONTENT }
98
98
  end
99
99
  end
100
100
  end
@@ -3,6 +3,8 @@ require 'fediverse/inbox'
3
3
  module Federails
4
4
  module Server
5
5
  class ActivitiesController < Federails::ServerController
6
+ include Federails::Server::RenderCollections
7
+
6
8
  before_action :set_activity, only: [:show]
7
9
 
8
10
  # GET /federation/activities
@@ -10,10 +12,16 @@ module Federails
10
12
  def outbox
11
13
  authorize Federails::Activity, policy_class: Federails::Server::ActivityPolicy
12
14
 
13
- @actor = Actor.find_param(params[:actor_id])
14
- @activities = policy_scope(Federails::Activity, policy_scope_class: Federails::Server::ActivityPolicy::Scope).where(actor: @actor).order(created_at: :desc)
15
- @total_activities = @activities.count
16
- @activities = @activities.page(params[:page])
15
+ actor = Actor.find_param(params[:actor_id])
16
+ activities = policy_scope(Federails::Activity, policy_scope_class: Federails::Server::ActivityPolicy::Scope).where(actor: actor).order(created_at: :desc)
17
+
18
+ render_collection(
19
+ collection: activities.page(params[:page]),
20
+ actor: actor,
21
+ url_helper: :server_actor_outbox_url
22
+ ) do |builder, items|
23
+ builder.array! items, partial: 'federails/server/activities/activity', as: :activity, context: false
24
+ end
17
25
  end
18
26
 
19
27
  # GET /federation/actors/1/activities/1.json
@@ -24,12 +32,12 @@ module Federails
24
32
  skip_authorization
25
33
 
26
34
  payload = payload_from_params
27
- return head :unprocessable_entity unless payload
35
+ return head Federails::Utils::ResponseCodes::UNPROCESSABLE_CONTENT unless payload
28
36
 
29
37
  if Fediverse::Inbox.dispatch_request(payload)
30
38
  head :created
31
39
  else
32
- head :unprocessable_entity
40
+ head Federails::Utils::ResponseCodes::UNPROCESSABLE_CONTENT
33
41
  end
34
42
  end
35
43
 
@@ -1,6 +1,8 @@
1
1
  module Federails
2
2
  module Server
3
3
  class ActorsController < Federails::ServerController
4
+ include Federails::Server::RenderCollections
5
+
4
6
  before_action :set_actor, only: [:show, :followers, :following]
5
7
 
6
8
  # GET /federation/actors/1
@@ -14,14 +16,26 @@ module Federails
14
16
  # GET /federation/actors/:id/followers.json
15
17
  def followers
16
18
  @actors = @actor.followers.order(created_at: :desc)
17
- followings_queries
19
+ render_collection(
20
+ collection: @actors.page(params[:page]),
21
+ actor: @actor,
22
+ url_helper: :followers_server_actor_url
23
+ ) do |builder, items|
24
+ builder.array! items.map(&:federated_url)
25
+ end
18
26
  end
19
27
 
20
28
  # GET /federation/actors/:id/followers
21
29
  # GET /federation/actors/:id/followers.json
22
30
  def following
23
31
  @actors = @actor.follows.order(created_at: :desc)
24
- followings_queries
32
+ render_collection(
33
+ collection: @actors.page(params[:page]),
34
+ actor: @actor,
35
+ url_helper: :following_server_actor_url
36
+ ) do |builder, items|
37
+ builder.array! items.map(&:federated_url)
38
+ end
25
39
  end
26
40
 
27
41
  private
@@ -31,11 +45,6 @@ module Federails
31
45
  @actor = Actor.find_param(params[:id])
32
46
  authorize @actor, policy_class: Federails::Server::ActorPolicy
33
47
  end
34
-
35
- def followings_queries
36
- @total_actors = @actors.count
37
- @actors = @actors.page(params[:page])
38
- end
39
48
  end
40
49
  end
41
50
  end
@@ -1,3 +1,5 @@
1
+ require 'federails/utils/context'
2
+
1
3
  module Federails
2
4
  module ServerHelper
3
5
  def remote_follow_url
@@ -8,5 +10,9 @@ module Federails
8
10
  Rails.application.routes.url_helpers.send(method_name)
9
11
  end
10
12
  end
13
+
14
+ def set_json_ld_context(json, additional: nil)
15
+ json.set! '@context', Federails::Utils::Context.generate(additional: additional)
16
+ end
11
17
  end
12
18
  end
@@ -1,5 +1,7 @@
1
1
  module Federails
2
2
  class ApplicationJob < ActiveJob::Base
3
+ queue_as { Configuration.job_queue.to_sym }
4
+
3
5
  discard_on ActiveJob::DeserializationError
4
6
  after_discard do |_job, exception|
5
7
  Rails.logger.info exception.to_s
@@ -0,0 +1,10 @@
1
+ require 'fediverse/notifier'
2
+
3
+ module Federails
4
+ class FetchNodeinfoJob < ApplicationJob
5
+ # @param domain [String] Domain to create/update
6
+ def perform(domain)
7
+ Federails::Host.create_or_update domain, min_update_interval: Federails::Configuration.remote_entities_cache_duration
8
+ end
9
+ end
10
+ end
@@ -2,8 +2,6 @@ require 'fediverse/notifier'
2
2
 
3
3
  module Federails
4
4
  class NotifyInboxJob < ApplicationJob
5
- queue_as :default
6
-
7
5
  def perform(activity)
8
6
  activity.reload
9
7
  Fediverse::Notifier.post_to_inboxes(activity)
@@ -156,11 +156,11 @@ module Federails
156
156
  end
157
157
 
158
158
  def tombstone_federails_actor!
159
- federails_actor.tombstone! if federails_actor.present?
159
+ federails_actor.presence&.tombstone!
160
160
  end
161
161
 
162
162
  def untombstone_federails_actor!
163
- federails_actor.untombstone! if federails_actor.present?
163
+ federails_actor.presence&.untombstone!
164
164
  end
165
165
  end
166
166
  end
@@ -24,31 +24,24 @@ module Federails
24
24
 
25
25
  after_create_commit :post_to_inboxes
26
26
 
27
- # Determines the list of actors targeted by the activity
28
- #
29
- # @return [Array<Federails::Actor>]
30
- def recipients
31
- return [] unless actor.local?
32
-
33
- case action
34
- when 'Follow'
35
- [entity]
36
- when 'Undo'
37
- [entity.entity]
38
- when 'Accept'
39
- [entity.actor]
40
- else
41
- default_recipient_list
42
- end
43
- end
27
+ before_validation :set_default_addressing, on: :create
28
+
29
+ serialize :cc, coder: YAML
30
+ serialize :to, coder: YAML
44
31
 
45
32
  private
46
33
 
47
- def default_recipient_list
48
- list = actor.followers
49
- # If local actor is the subject, notify that actor's followers as well
50
- list += entity.followers if entity.is_a?(Federails::Actor) && entity.local?
51
- list.uniq
34
+ # Sets up default public-and-followers addressing unless to and cc are already set
35
+ #
36
+ # This retains compatibility with previous behaviour
37
+ def set_default_addressing
38
+ return if to.present? || cc.present?
39
+
40
+ self.to = [Fediverse::Collection::PUBLIC]
41
+ self.cc = [
42
+ actor.followers_url,
43
+ (entity.try(:followers_url) if entity.try(:local?)),
44
+ ].compact.uniq
52
45
  end
53
46
 
54
47
  def post_to_inboxes
@@ -37,12 +37,18 @@ module Federails
37
37
  has_many :followers, source: :actor, through: :following_followers
38
38
  # Actors followed by actor
39
39
  has_many :follows, source: :target_actor, through: :following_follows
40
+ belongs_to :host, class_name: 'Federails::Host', foreign_key: :server, primary_key: :domain, inverse_of: :actors, optional: true
41
+
42
+ # Explicitly explain serialization for MariaDB
43
+ attribute :extensions, :json
40
44
 
41
45
  scope :local, -> { where(local: true) }
42
46
  scope :distant, -> { where(local: false) }
43
47
  scope :tombstoned, -> { where.not(tombstoned_at: nil) }
44
48
  scope :not_tombstoned, -> { where(tombstoned_at: nil) }
45
49
 
50
+ after_create -> { FetchNodeinfoJob.perform_later(server) }, unless: :local?
51
+
46
52
  on_federails_delete_requested -> { tombstone! }
47
53
  on_federails_undelete_requested -> { untombstone! }
48
54
 
@@ -116,7 +122,7 @@ module Federails
116
122
  # @return [Federails::Following, false]
117
123
  def follows?(actor)
118
124
  list = following_follows.where target_actor: actor
119
- return list.first if list.count == 1
125
+ return list.first if list.one?
120
126
 
121
127
  false
122
128
  end
@@ -126,7 +132,7 @@ module Federails
126
132
  # @return [Federails::Following, false]
127
133
  def followed_by?(actor)
128
134
  list = following_followers.where actor: actor
129
- return list.first if list.count == 1
135
+ return list.first if list.one?
130
136
 
131
137
  false
132
138
  end
@@ -28,7 +28,7 @@ module Federails
28
28
 
29
29
  def accept!
30
30
  update! status: :accepted
31
- Activity.create! actor: target_actor, action: 'Accept', entity: self
31
+ Activity.create! actor: target_actor, action: 'Accept', entity: self, to: [actor.federated_url]
32
32
  end
33
33
 
34
34
  def follow_activity
@@ -62,11 +62,11 @@ module Federails
62
62
  end
63
63
 
64
64
  def create_activity
65
- Activity.create! actor: actor, action: 'Follow', entity: target_actor
65
+ Activity.create! actor: actor, action: 'Follow', entity: target_actor, to: [target_actor.federated_url]
66
66
  end
67
67
 
68
68
  def destroy_activity
69
- Activity.create! actor: actor, action: 'Undo', entity: follow_activity
69
+ Activity.create! actor: actor, action: 'Undo', entity: follow_activity, to: [target_actor.federated_url]
70
70
  end
71
71
  end
72
72
  end
@@ -0,0 +1,48 @@
1
+ require 'fediverse/node_info'
2
+
3
+ module Federails
4
+ class Host < ApplicationRecord
5
+ attribute :protocols, :json
6
+ attribute :services, :json
7
+
8
+ validates :domain, presence: true, allow_blank: false, uniqueness: true
9
+
10
+ # No "dependent" option here as this is not a hard reference, and we want to keep the actors if the host gets deleted
11
+ has_many :actors, class_name: 'Federails::Actor', primary_key: :domain, foreign_key: :server, inverse_of: :host # rubocop:disable Rails/HasManyOrHasOneDependent
12
+
13
+ scope :same_app, -> { where software_name: Configuration.app_name }
14
+ scope :same_app_and_version, -> { same_app.where app_version: Configuration.app_version }
15
+
16
+ def same_app?
17
+ software_name == Configuration.app_name
18
+ end
19
+
20
+ def same_app_and_version?
21
+ software_name == Configuration.app_name && app_version == Configuration.app_version
22
+ end
23
+
24
+ # Update from remote data
25
+ def sync!
26
+ update! Fediverse::NodeInfo.fetch(domain)
27
+ end
28
+
29
+ class << self
30
+ # Creates or update a Host
31
+ #
32
+ # @param domain [String] Domain to check
33
+ # @param min_update_interval [Integer, ActiveSupport::Duration] Minimum amount of seconds since the last update to fetch fresh data
34
+ def create_or_update(domain, min_update_interval: 0)
35
+ entry = find_or_initialize_by domain: domain
36
+ return if min_update_interval && entry.persisted? && (entry.updated_at + min_update_interval) > Time.current
37
+
38
+ entry.sync!
39
+
40
+ entry
41
+ rescue Fediverse::NodeInfo::NoActivityPubError
42
+ Rails.logger.info { "#{domain} does not provide ActivityPub service" }
43
+ rescue Federails::Utils::JsonRequest::UnhandledResponseStatus, Faraday::SSLError => e
44
+ Rails.logger.info { "Error connecting to #{domain}: '#{e.message}'" }
45
+ end
46
+ end
47
+ end
48
+ end
@@ -1,13 +1,17 @@
1
1
  context = true unless context == false
2
2
  addressing = true unless addressing == false
3
- json.set! '@context', 'https://www.w3.org/ns/activitystreams' if context
3
+ set_json_ld_context(json) if context
4
4
 
5
5
  json.id Federails::Engine.routes.url_helpers.server_actor_activity_url activity.actor, activity
6
6
  json.type activity.action
7
7
  json.actor activity.actor.federated_url
8
8
  if addressing
9
- json.to ['https://www.w3.org/ns/activitystreams#Public']
10
- json.cc [activity.actor.followers_url]
9
+ json.merge!(
10
+ {
11
+ to: activity.to,
12
+ cc: activity.cc,
13
+ }.compact
14
+ )
11
15
  end
12
16
 
13
17
  if activity.entity.is_a? Federails::Activity
@@ -1,11 +1,12 @@
1
1
  actor_data = actor.entity&.to_activitypub_object || {}
2
2
 
3
- json.set! '@context', ([
4
- 'https://www.w3.org/ns/activitystreams',
5
- 'https://w3id.org/security/v1',
6
- ] + [
7
- actor_data&.delete(:@context),
8
- ].flatten).compact
3
+ set_json_ld_context(
4
+ json,
5
+ additional: [
6
+ 'https://w3id.org/security/v1',
7
+ actor_data.delete(:@context),
8
+ ]
9
+ )
9
10
 
10
11
  json.id actor.federated_url
11
12
  json.name actor.name
@@ -1,7 +1,4 @@
1
- json.set! '@context', [
2
- 'https://www.w3.org/ns/activitystreams',
3
- 'https://w3id.org/security/v1',
4
- ]
1
+ set_json_ld_context(json)
5
2
 
6
3
  json.id actor.federated_url
7
4
  json.type 'Tombstone'
@@ -1,5 +1,5 @@
1
1
  context = true unless context == false
2
- json.set! '@context', 'https://www.w3.org/ns/activitystreams' if context
2
+ set_json_ld_context(json) if context
3
3
 
4
4
  json.id following.federated_url
5
5
  json.type 'Follow'
@@ -1,6 +1,5 @@
1
1
  json.version '2.0'
2
- # FIXME: Use configuration values when created
3
- json.software name: Federails::Configuration.app_name,
2
+ json.software name: Federails::Configuration.app_name&.parameterize,
4
3
  version: Federails::Configuration.app_version
5
4
  json.protocols [
6
5
  'activitypub',
@@ -17,4 +16,4 @@ if @has_user_counts
17
16
  activeHalfyear: @active_halfyear,
18
17
  }
19
18
  end
20
- json.metadata({})
19
+ json.metadata(Federails::Configuration.nodeinfo_metadata || {})
@@ -1,5 +1,5 @@
1
1
  context = true unless context == false
2
- json.set! '@context', 'https://www.w3.org/ns/activitystreams' if context
2
+ set_json_ld_context(json) if context
3
3
 
4
4
  publishable.to_activitypub_object.each_pair do |key, value|
5
5
  json.set! key, value
@@ -7,5 +7,5 @@ end
7
7
 
8
8
  json.id publishable.federated_url
9
9
  json.actor publishable.federails_actor.federated_url
10
- json.to ['https://www.w3.org/ns/activitystreams#Public']
10
+ json.to [Fediverse::Collection::PUBLIC]
11
11
  json.cc [publishable.federails_actor.followers_url]
@@ -1,7 +1,4 @@
1
- json.set! '@context', [
2
- 'https://www.w3.org/ns/activitystreams',
3
- 'https://w3id.org/security/v1',
4
- ]
1
+ set_json_ld_context(json)
5
2
 
6
3
  json.id publishable.federated_url
7
4
  json.type 'Tombstone'
@@ -0,0 +1,6 @@
1
+ set_json_ld_context(json)
2
+ json.id send(url_helper, actor)
3
+ json.type 'OrderedCollection'
4
+ json.totalItems collection.total_count
5
+ json.first send(url_helper, actor, page: 1)
6
+ json.last send(url_helper, actor, page: collection.total_pages)
@@ -0,0 +1,12 @@
1
+ set_json_ld_context(json)
2
+ json.type 'OrderedCollectionPage'
3
+ json.id send(url_helper, actor, page: collection.current_page)
4
+ json.partOf send(url_helper, actor)
5
+ json.first send(url_helper, actor, page: 1)
6
+ json.last send(url_helper, actor, page: collection.total_pages)
7
+ json.next send(url_helper, actor, page: collection.next_page) if collection.next_page
8
+ json.prev send(url_helper, actor, page: collection.prev_page) if collection.prev_page
9
+ json.totalItems collection.total_count
10
+ json.orderedItems do
11
+ items_block.call(json, collection)
12
+ end
@@ -0,0 +1,4 @@
1
+ This page renders a users profile; its route (<code>user_url</code>) is used as <code>:profile_url_method</code>
2
+ in Federails integration (<code>acts_as_actor_entity</code> in <code>User</code> model).
3
+
4
+ <pre><%= JSON.pretty_generate @user.as_json %></pre>
@@ -0,0 +1,22 @@
1
+ class CreateFederailsHosts < ActiveRecord::Migration[7.2]
2
+ def change
3
+ create_table :federails_hosts do |t|
4
+ t.string :domain, null: false, default: nil
5
+ t.string :nodeinfo_url
6
+ t.string :software_name
7
+ t.string :software_version
8
+
9
+ # Uncomment the lines below if you use PostgreSQL
10
+ # t.jsonb :protocols, default: []
11
+ # t.jsonb :services, default: {}
12
+ #
13
+ # Other databases
14
+ t.text :protocols, default: '[]'
15
+ t.text :services, default: '{}'
16
+
17
+ t.timestamps
18
+
19
+ t.index :domain, unique: true
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,6 @@
1
+ class AddToAndCcToFederailsActivities < ActiveRecord::Migration[7.2]
2
+ def change
3
+ add_column :federails_activities, :to, :string
4
+ add_column :federails_activities, :cc, :string
5
+ end
6
+ end
@@ -35,6 +35,14 @@ module Federails
35
35
  @@open_registrations.is_a?(Proc) ? @@open_registrations.call : @@open_registrations
36
36
  end
37
37
 
38
+ # Custom metadata for nodeinfo
39
+ # Can either be a static hash, or a Proc which will be called to get the state.
40
+ mattr_writer :nodeinfo_metadata
41
+ @@nodeinfo_metadata = {}
42
+ def self.nodeinfo_metadata
43
+ @@nodeinfo_metadata.is_a?(Proc) ? @@nodeinfo_metadata.call : @@nodeinfo_metadata
44
+ end
45
+
38
46
  # Application layout
39
47
  mattr_accessor :app_layout
40
48
  @@app_layout = nil
@@ -81,6 +89,16 @@ module Federails
81
89
  Federails::Engine.routes.default_url_options[:port] = value
82
90
  end
83
91
 
92
+ # Default amount of seconds to consider that a remote entity could be updated
93
+ #
94
+ # This setting is used for hosts information only, for now.
95
+ mattr_accessor :remote_entities_cache_duration
96
+ @@remote_entities_cache_duration = 1.day
97
+
98
+ # Job queue name
99
+ mattr_accessor :job_queue
100
+ @@job_queue = :default
101
+
84
102
  # List of actor types (classes using Federails::ActorEntity)
85
103
  mattr_reader :actor_types
86
104
  @@actor_types = {}
@@ -1,3 +1,5 @@
1
+ require 'federails/utils/context'
2
+
1
3
  module Federails
2
4
  module DataTransformer
3
5
  module Note
@@ -17,7 +19,10 @@ module Federails
17
19
  # - https://www.w3.org/TR/activitystreams-vocabulary/#dfn-object
18
20
  # - https://www.w3.org/TR/activitystreams-vocabulary/#dfn-note
19
21
  def self.to_federation(entity, content:, name: nil, custom: {})
20
- custom.merge '@context' => 'https://www.w3.org/ns/activitystreams',
22
+ # Merge default and custom contexts
23
+ context = Utils::Context.generate(additional: custom.delete('@context'))
24
+ # Merge in standard Note fields
25
+ custom.merge '@context' => context,
21
26
  'id' => entity.federated_url,
22
27
  'type' => 'Note',
23
28
  'name' => name,
@@ -0,0 +1,19 @@
1
+ module Federails
2
+ module Maintenance
3
+ class HostsUpdater
4
+ class << self
5
+ # Update information for all known hosts, and complete if some are missing
6
+ def run(cache_interval: nil)
7
+ cache_interval ||= Federails::Configuration.remote_entities_cache_duration
8
+
9
+ domains = Federails::Actor.distant.distinct(:server).pluck(:server) + Federails::Host.pluck(:domain)
10
+ domains.uniq!
11
+
12
+ domains.each do |domain|
13
+ Federails::Host.create_or_update domain, min_update_interval: cache_interval
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,12 @@
1
+ module Federails
2
+ module Utils
3
+ module Context
4
+ class << self
5
+ def generate(additional: nil)
6
+ activity_streams = 'https://www.w3.org/ns/activitystreams'
7
+ additional.nil? ? activity_streams : [activity_streams, additional].flatten.compact
8
+ end
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,11 @@
1
+ module Federails
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,3 +1,3 @@
1
1
  module Federails
2
- VERSION = '0.7.0'.freeze
2
+ VERSION = '0.8.0'.freeze
3
3
  end
data/lib/federails.rb CHANGED
@@ -7,6 +7,9 @@ require 'federails/engine'
7
7
  require 'federails/configuration'
8
8
  require 'federails/utils/object'
9
9
  require 'federails/utils/json_request'
10
+ require 'federails/utils/response_codes'
11
+
12
+ require 'fediverse'
10
13
 
11
14
  # rubocop:disable Style/ClassVars
12
15
 
@@ -35,11 +38,14 @@ module Federails
35
38
  :site_port,
36
39
  :enable_discovery,
37
40
  :open_registrations,
41
+ :nodeinfo_metadata,
38
42
  :app_layout,
39
43
  :server_routes_path,
40
44
  :client_routes_path,
41
45
  :remote_follow_url_method,
42
46
  :base_client_controller,
47
+ :remote_entities_cache_duration,
48
+ :job_queue,
43
49
  ].each { |key| Configuration.send :"#{key}=", config[key] if config.key?(key) }
44
50
  end
45
51
 
@@ -82,9 +88,11 @@ module Federails
82
88
  # @return [Hash, nil] Data entity configuration
83
89
  def data_entity_handler_for(hash)
84
90
  data_entity_handlers_for(hash['type']).find do |handler|
85
- return true if !handler[:filter_method] && !handler[:class].respond_to?(DEFAULT_DATA_FILTER_METHOD)
86
-
87
- handler[:class].send(handler[:filter_method] || DEFAULT_DATA_FILTER_METHOD, hash)
91
+ if handler[:filter_method] || handler[:class].respond_to?(DEFAULT_DATA_FILTER_METHOD)
92
+ handler[:class].send(handler[:filter_method] || DEFAULT_DATA_FILTER_METHOD, hash)
93
+ else
94
+ true
95
+ end
88
96
  end
89
97
  end
90
98
 
@@ -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
@@ -7,18 +7,45 @@ module Fediverse
7
7
  #
8
8
  # @param activity [Federails::Activity]
9
9
  def post_to_inboxes(activity)
10
- actors = activity.recipients
11
- Rails.logger.debug('Nobody to notice') && return if actors.count.zero?
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?
12
13
 
14
+ # Deliver to each inbox
13
15
  message = payload(activity)
14
- actors.each do |recipient|
15
- Rails.logger.debug { "Sending activity ##{activity.id} to #{recipient.inbox_url}" }
16
- post_to_inbox(to: recipient, message: message, from: activity.actor)
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)
17
19
  end
18
20
  end
19
21
 
20
22
  private
21
23
 
24
+ # Determines the list of inboxes that the activity should be delivered to
25
+ #
26
+ # @return [Array<Federails::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 = Federails::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
+ Federails::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
+
22
49
  def payload(activity)
23
50
  Federails::ServerController.renderer.new.render(
24
51
  template: 'federails/server/activities/show',
@@ -27,27 +54,27 @@ module Fediverse
27
54
  )
28
55
  end
29
56
 
30
- def post_to_inbox(to:, message:, from: nil)
57
+ def post_to_inbox(inbox_url:, message:, from: nil)
31
58
  conn = Faraday.default_connection
32
59
  conn.builder.build_response(
33
60
  conn,
34
- signed_request(to: to, message: message, from: from)
61
+ signed_request(url: inbox_url, message: message, from: from)
35
62
  )
36
63
  end
37
64
 
38
- def signed_request(to:, message:, from:)
39
- req = request(to: to, message: message)
65
+ def signed_request(url:, message:, from:)
66
+ req = request(url: url, message: message)
40
67
  req.headers['Signature'] = Fediverse::Signature.sign(sender: from, request: req) if from
41
68
  req
42
69
  end
43
70
 
44
- def request(to:, message:) # rubocop:todo Metrics/AbcSize
71
+ def request(url:, message:) # rubocop:todo Metrics/AbcSize
45
72
  Faraday.default_connection.build_request(:post) do |req|
46
- req.url to.inbox_url
73
+ req.url url
47
74
  req.body = message
48
75
  req.headers['Content-Type'] = Mime[:activitypub].to_s
49
76
  req.headers['Accept'] = Mime[:activitypub].to_s
50
- req.headers['Host'] = URI.parse(to.inbox_url).host
77
+ req.headers['Host'] = URI.parse(url).host
51
78
  req.headers['Date'] = Time.now.utc.httpdate
52
79
  req.headers['Digest'] = digest(message)
53
80
  end
@@ -20,7 +20,7 @@ module Fediverse
20
20
  raise 'Unsigned headers' unless request.headers['Signature']
21
21
 
22
22
  signature_header = request.headers['Signature'].split(',').to_h do |pair|
23
- /\A(?<key>[\w]+)="(?<value>.*)"\z/ =~ pair
23
+ /\A(?<key>\w+)="(?<value>.*)"\z/ =~ pair
24
24
  [key, value]
25
25
  end
26
26
 
data/lib/fediverse.rb CHANGED
@@ -1,3 +1,5 @@
1
+ require 'fediverse/collection'
2
+
1
3
  # This module includes classes and helpers to interact with the Fediverse.
2
4
  module Fediverse
3
5
  end
@@ -1,20 +1,42 @@
1
1
  module Federails
2
2
  class CopyFactoriesGenerator < Rails::Generators::Base
3
3
  SOURCE_DIRECTORY = File.expand_path('../../../../spec/factories/federails', __dir__)
4
+ FACTORY_DEFINITION_REGEX = /(FactoryBot.define do\n\s+factory) :(\w+),/
4
5
 
5
6
  source_root SOURCE_DIRECTORY
6
7
 
8
+ def initialize
9
+ super
10
+ @files = []
11
+ @factories = []
12
+ end
13
+
7
14
  def copy_factories
8
15
  dest = Rails.root.join('spec', 'factories')
9
16
 
10
- Dir.entries(SOURCE_DIRECTORY)
11
- .each do |node|
17
+ Dir.entries(SOURCE_DIRECTORY).each do |node|
12
18
  source_path = File.join(SOURCE_DIRECTORY, node)
13
19
  next unless File.file?(source_path) && node.match?(/\.rb\Z/)
14
20
 
15
21
  file_path = File.join(dest, "federails_#{node}")
16
22
  copy_file node, file_path
17
- gsub_file file_path, /(FactoryBot.define do\n\s+factory) :(\w+),/, '\1 :federails_\2,'
23
+ @files << file_path
24
+ @factories << File.read(file_path).match(FACTORY_DEFINITION_REGEX)&.[](2)
25
+ end
26
+
27
+ substitute_values!
28
+ end
29
+
30
+ private
31
+
32
+ def substitute_values!
33
+ @factories.compact!
34
+ @files.each do |file|
35
+ gsub_file file, FACTORY_DEFINITION_REGEX, '\1 :federails_\2,'
36
+
37
+ @factories.each do |factory|
38
+ gsub_file file, ":#{factory}", ":federails_#{factory}"
39
+ end
18
40
  end
19
41
  end
20
42
  end
@@ -10,6 +10,8 @@ defaults: &defaults
10
10
  app_layout: 'layouts/application'
11
11
  server_routes_path: federation
12
12
  client_routes_path: app
13
+ job_queue: default
14
+ #remote_entities_cache_duration: 86400 # 1 day in seconds
13
15
  #base_client_controller: ::ActionController::Base
14
16
 
15
17
  development:
@@ -5,4 +5,9 @@ namespace :federails do
5
5
  puts "#{actor.federated_url}: #{status}"
6
6
  end
7
7
  end
8
+
9
+ desc 'Re-fetches every host and completes missing ones'
10
+ task sync_hosts: :environment do
11
+ Federails::Maintenance::HostsUpdater.run
12
+ end
8
13
  end
metadata CHANGED
@@ -1,13 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: federails
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.0
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Manuel Tancoigne
8
+ autorequire:
8
9
  bindir: bin
9
10
  cert_chain: []
10
- date: 2025-05-10 00:00:00.000000000 Z
11
+ date: 2026-03-25 00:00:00.000000000 Z
11
12
  dependencies:
12
13
  - !ruby/object:Gem::Dependency
13
14
  name: faraday
@@ -133,6 +134,7 @@ files:
133
134
  - Rakefile
134
135
  - app/assets/config/federails_manifest.js
135
136
  - app/assets/stylesheets/federails/application.css
137
+ - app/controllers/concerns/federails/server/render_collections.rb
136
138
  - app/controllers/federails/client/activities_controller.rb
137
139
  - app/controllers/federails/client/actors_controller.rb
138
140
  - app/controllers/federails/client/followings_controller.rb
@@ -146,6 +148,7 @@ files:
146
148
  - app/controllers/federails/server_controller.rb
147
149
  - app/helpers/federails/server_helper.rb
148
150
  - app/jobs/federails/application_job.rb
151
+ - app/jobs/federails/fetch_nodeinfo_job.rb
149
152
  - app/jobs/federails/notify_inbox_job.rb
150
153
  - app/mailers/federails/application_mailer.rb
151
154
  - app/models/concerns/federails/actor_entity.rb
@@ -156,6 +159,7 @@ files:
156
159
  - app/models/federails/actor.rb
157
160
  - app/models/federails/application_record.rb
158
161
  - app/models/federails/following.rb
162
+ - app/models/federails/host.rb
159
163
  - app/policies/federails/client/activity_policy.rb
160
164
  - app/policies/federails/client/actor_policy.rb
161
165
  - app/policies/federails/client/following_policy.rb
@@ -189,12 +193,9 @@ files:
189
193
  - app/views/federails/client/followings/show.html.erb
190
194
  - app/views/federails/client/followings/show.json.jbuilder
191
195
  - app/views/federails/server/activities/_activity.activitypub.jbuilder
192
- - app/views/federails/server/activities/outbox.activitypub.jbuilder
193
196
  - app/views/federails/server/activities/show.activitypub.jbuilder
194
197
  - app/views/federails/server/actors/_actor.activitypub.jbuilder
195
198
  - app/views/federails/server/actors/_tombstone.activitypub.jbuilder
196
- - app/views/federails/server/actors/followers.activitypub.jbuilder
197
- - app/views/federails/server/actors/following.activitypub.jbuilder
198
199
  - app/views/federails/server/actors/show.activitypub.jbuilder
199
200
  - app/views/federails/server/followings/_following.activitypub.jbuilder
200
201
  - app/views/federails/server/followings/show.activitypub.jbuilder
@@ -203,8 +204,11 @@ files:
203
204
  - app/views/federails/server/published/_publishable.activitypub.jbuilder
204
205
  - app/views/federails/server/published/_tombstone.activitypub.jbuilder
205
206
  - app/views/federails/server/published/show.activitypub.jbuilder
207
+ - app/views/federails/server/shared/ordered_collection.activitypub.jbuilder
208
+ - app/views/federails/server/shared/ordered_collection_page.activitypub.jbuilder
206
209
  - app/views/federails/server/web_finger/find.jrd.jbuilder
207
210
  - app/views/federails/server/web_finger/host_meta.xrd.erb
211
+ - app/views/users/show.html.erb
208
212
  - config/initializers/mime_types.rb
209
213
  - config/routes.rb
210
214
  - db/migrate/20200712133150_create_federails_actors.rb
@@ -216,17 +220,23 @@ files:
216
220
  - db/migrate/20250301082500_add_local_to_actors.rb
217
221
  - db/migrate/20250329123939_add_actor_type_to_actors.rb
218
222
  - db/migrate/20250329123940_add_tombstoned_at_to_actors.rb
223
+ - db/migrate/20250426061729_create_federails_hosts.rb
224
+ - db/migrate/20251121160720_add_to_and_cc_to_federails_activities.rb
219
225
  - lib/federails.rb
220
226
  - lib/federails/configuration.rb
221
227
  - lib/federails/data_transformer/note.rb
222
228
  - lib/federails/engine.rb
223
229
  - lib/federails/maintenance/actors_updater.rb
230
+ - lib/federails/maintenance/hosts_updater.rb
224
231
  - lib/federails/utils/actor.rb
232
+ - lib/federails/utils/context.rb
225
233
  - lib/federails/utils/host.rb
226
234
  - lib/federails/utils/json_request.rb
227
235
  - lib/federails/utils/object.rb
236
+ - lib/federails/utils/response_codes.rb
228
237
  - lib/federails/version.rb
229
238
  - lib/fediverse.rb
239
+ - lib/fediverse/collection.rb
230
240
  - lib/fediverse/inbox.rb
231
241
  - lib/fediverse/node_info.rb
232
242
  - lib/fediverse/notifier.rb
@@ -253,6 +263,7 @@ metadata:
253
263
  homepage_uri: https://experimentslabs.com
254
264
  source_code_uri: https://gitlab.com/experimentslabs/federails/
255
265
  changelog_uri: https://gitlab.com/experimentslabs/federails/-/blob/main/CHANGELOG.md
266
+ post_install_message:
256
267
  rdoc_options: []
257
268
  require_paths:
258
269
  - lib
@@ -267,7 +278,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
267
278
  - !ruby/object:Gem::Version
268
279
  version: '0'
269
280
  requirements: []
270
- rubygems_version: 3.6.5
281
+ rubygems_version: 3.3.7
282
+ signing_key:
271
283
  specification_version: 4
272
284
  summary: An ActivityPub engine for Ruby on Rails
273
285
  test_files: []
@@ -1,18 +0,0 @@
1
- json.set!('@context', 'https://www.w3.org/ns/activitystreams')
2
- collection_id = @actor.outbox_url
3
- json.id collection_id
4
- json.type 'OrderedCollectionPage'
5
- json.totalItems @total_activities
6
- json.first collection_id
7
- json.last @activities.total_pages == 1 ? Federails::Engine.routes.url_helpers.server_actor_outbox_url(@actor) : Federails::Engine.routes.url_helpers.server_actor_outbox_url(@actor, page: @activities.total_pages)
8
- json.current do |j|
9
- j.type 'OrderedCollectionPage'
10
- j.id @activities.current_page == 1 ? Federails::Engine.routes.url_helpers.server_actor_outbox_url(@actor) : Federails::Engine.routes.url_helpers.server_actor_outbox_url(@actor, page: @activities.current_page)
11
- j.partOf collection_id
12
- j.next @activities.next_page
13
- j.prev @activities.prev_page
14
- j.totalItems @total_activities
15
- j.orderedItems do
16
- json.array! @activities, partial: 'federails/server/activities/activity', as: :activity, context: false
17
- end
18
- end
@@ -1,18 +0,0 @@
1
- json.set!('@context', 'https://www.w3.org/ns/activitystreams')
2
- collection_id = @actor.followers_url
3
- json.id collection_id
4
- json.type 'OrderedCollectionPage'
5
- json.totalItems @total_actors
6
- json.first Federails::Engine.routes.url_helpers.followers_server_actor_url(@actor)
7
- json.last @actors.total_pages == 1 ? Federails::Engine.routes.url_helpers.followers_server_actor_url(@actor) : Federails::Engine.routes.url_helpers.followers_server_actor_url(@actor, page: @actors.total_pages)
8
- json.current do |j|
9
- j.type 'OrderedCollectionPage'
10
- j.id @actors.current_page == 1 ? Federails::Engine.routes.url_helpers.followers_server_actor_url(@actor) : Federails::Engine.routes.url_helpers.followers_server_actor_url(@actor, page: @actors.current_page)
11
- j.partOf collection_id
12
- j.next @actors.next_page
13
- j.prev @actors.prev_page
14
- j.totalItems @total_actors
15
- j.orderedItems do
16
- json.array! @actors.map(&:federated_url)
17
- end
18
- end
@@ -1,18 +0,0 @@
1
- json.set!('@context', 'https://www.w3.org/ns/activitystreams')
2
- collection_id = @actor.followings_url
3
- json.id collection_id
4
- json.type 'OrderedCollectionPage'
5
- json.totalItems @total_actors
6
- json.first Federails::Engine.routes.url_helpers.following_server_actor_url(@actor)
7
- json.last @actors.total_pages == 1 ? Federails::Engine.routes.url_helpers.following_server_actor_url(@actor) : Federails::Engine.routes.url_helpers.following_server_actor_url(@actor, page: @actors.total_pages)
8
- json.current do |j|
9
- j.type 'OrderedCollectionPage'
10
- j.id @actors.current_page == 1 ? Federails::Engine.routes.url_helpers.following_server_actor_url(@actor) : Federails::Engine.routes.url_helpers.following_server_actor_url(@actor, page: @actors.current_page)
11
- j.partOf collection_id
12
- j.next @actors.next_page
13
- j.prev @actors.prev_page
14
- j.totalItems @total_actors
15
- j.orderedItems do
16
- json.array! @actors.map(&:federated_url)
17
- end
18
- end