federails 0.1.0 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +11 -0
  3. data/app/controllers/federails/application_controller.rb +4 -0
  4. data/app/controllers/federails/client/activities_controller.rb +1 -1
  5. data/app/controllers/federails/client/actors_controller.rb +1 -1
  6. data/app/controllers/federails/client/followings_controller.rb +9 -1
  7. data/app/controllers/federails/server/activities_controller.rb +5 -5
  8. data/app/controllers/federails/server/actors_controller.rb +1 -1
  9. data/app/controllers/federails/server/followings_controller.rb +2 -1
  10. data/app/controllers/federails/server/nodeinfo_controller.rb +2 -2
  11. data/app/controllers/federails/server/web_finger_controller.rb +2 -2
  12. data/app/helpers/federails/application_helper.rb +8 -0
  13. data/app/models/concerns/federails/entity.rb +12 -1
  14. data/app/models/concerns/federails/has_uuid.rb +35 -0
  15. data/app/models/federails/activity.rb +8 -13
  16. data/app/models/federails/actor.rb +38 -1
  17. data/app/models/federails/following.rb +9 -0
  18. data/app/views/federails/client/actors/show.html.erb +1 -1
  19. data/app/views/federails/server/activities/{_activity.json.jbuilder → _activity.activitypub.jbuilder} +6 -1
  20. data/app/views/federails/server/actors/{_actor.json.jbuilder → _actor.activitypub.jbuilder} +11 -1
  21. data/app/views/federails/server/web_finger/{find.json.jbuilder → find.jrd.jbuilder} +5 -1
  22. data/config/initializers/mime_types.rb +21 -0
  23. data/config/routes.rb +1 -1
  24. data/db/migrate/20241002094500_add_uuids.rb +13 -0
  25. data/db/migrate/20241002094501_add_keypair_to_actors.rb +8 -0
  26. data/lib/federails/configuration.rb +4 -0
  27. data/lib/federails/version.rb +1 -1
  28. data/lib/federails.rb +2 -1
  29. data/lib/fediverse/notifier.rb +44 -5
  30. data/lib/fediverse/signature.rb +49 -0
  31. data/lib/fediverse/webfinger.rb +31 -7
  32. metadata +20 -15
  33. /data/app/views/federails/server/activities/{outbox.json.jbuilder → outbox.activitypub.jbuilder} +0 -0
  34. /data/app/views/federails/server/activities/{show.json.jbuilder → show.activitypub.jbuilder} +0 -0
  35. /data/app/views/federails/server/actors/{followers.json.jbuilder → followers.activitypub.jbuilder} +0 -0
  36. /data/app/views/federails/server/actors/{following.json.jbuilder → following.activitypub.jbuilder} +0 -0
  37. /data/app/views/federails/server/actors/{show.json.jbuilder → show.activitypub.jbuilder} +0 -0
  38. /data/app/views/federails/server/followings/{_following.json.jbuilder → _following.activitypub.jbuilder} +0 -0
  39. /data/app/views/federails/server/followings/{show.json.jbuilder → show.activitypub.jbuilder} +0 -0
  40. /data/app/views/federails/server/nodeinfo/{index.json.jbuilder → index.nodeinfo.jbuilder} +0 -0
  41. /data/app/views/federails/server/nodeinfo/{show.json.jbuilder → show.nodeinfo.jbuilder} +0 -0
  42. /data/app/views/federails/server/web_finger/{host_meta.xml.erb → host_meta.xrd.erb} +0 -0
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bc485556cd2ae749668f8f6a6ffec973361bdef6970e421845b9c2de64e8cecf
4
- data.tar.gz: 34630a4b116e47b3d4c3ec4fd7599ba0264f4dd76fdff5db795d2be90c50c2cc
3
+ metadata.gz: 070da5d2cc3d016475a69f797c6280595618d5f60de95cf7506bb2a1e9cbce4c
4
+ data.tar.gz: a954535a092fa71fdb911f973363d8372b6d56e42b32190394e92026567c6913
5
5
  SHA512:
6
- metadata.gz: b296b6db88b1077326722a187f6074fc8f9bbeb4f43d0f90a2c7811fb7bfd14b0e247f64149ce41c0e53128ce45d3e7e61ecfb0da8d47e64b6b7f188dfa33c8c
7
- data.tar.gz: 948085a88295cf6c6699ed1dbea2a64a30d8695bb1a9d596cb83d66753eb4e26b5129d0d00574d21f230544b75a3f859411e64c785878abc2ce85021ef41a2b5
6
+ metadata.gz: 69fba1438beef26a150079c2a39eb185903025dcad3c24f5dfee941d844710d12938cf5546f0dc488f725096a2c4e6f32dfba4d1d3c825b9ba9418f063bb4ec9
7
+ data.tar.gz: 01ef6821f7b1aa9422f013e23ed20b1225f0e630d4925ca81c7490f43d790a1d77a3b93e8d72783d93aee59f1760b48bbdd71a59feca353bca0a1277a6bd03c0
data/README.md CHANGED
@@ -102,6 +102,17 @@ Federails.configure do |config|
102
102
  end
103
103
  ```
104
104
 
105
+ #### Remote following
106
+
107
+ By default, remote follow requests (where you press a follow button on another server and get redirected home to complete the follow)
108
+ will use the built-in client paths. If you're not using the client, or want to provide your own user interface, you can set the path like this, assuming that `new_follow_url` is a valid route in your app. A `uri` query parameter template will be automatically appended, you don't need to specify that.
109
+
110
+ ```
111
+ Federails.configure do |config|
112
+ config.remote_follow_url_method = :new_follow_url
113
+ end
114
+ ```
115
+
105
116
  ### Migrations
106
117
 
107
118
  Copy the migrations:
@@ -11,6 +11,10 @@ module Federails
11
11
  def error_fallback(exception, fallback_message, status)
12
12
  message = exception&.message || fallback_message
13
13
  respond_to do |format|
14
+ format.jrd { head status }
15
+ format.xrd { head status }
16
+ format.activitypub { head status }
17
+ format.nodeinfo { head status }
14
18
  format.json { render json: { error: message }, status: status }
15
19
  format.html { raise exception }
16
20
  end
@@ -8,7 +8,7 @@ module Federails
8
8
  # GET /app/activities.json
9
9
  def index
10
10
  @activities = policy_scope(Federails::Activity, policy_scope_class: Federails::Client::ActivityPolicy::Scope).all
11
- @activities = @activities.where actor_id: params[:actor_id] if params[:actor_id]
11
+ @activities = @activities.where actor: Actor.find_param(params[:actor_id]) if params[:actor_id]
12
12
  end
13
13
 
14
14
  # GET /app/feed
@@ -25,7 +25,7 @@ module Federails
25
25
 
26
26
  # Use callbacks to share common setup or constraints between actions.
27
27
  def set_actor
28
- @actor = Federails::Actor.find(params[:id])
28
+ @actor = Federails::Actor.find_param(params[:id])
29
29
  authorize @actor, policy_class: Federails::Client::ActorPolicy
30
30
  end
31
31
 
@@ -4,6 +4,14 @@ module Federails
4
4
  before_action :authenticate_user!
5
5
  before_action :set_following, only: [:accept, :destroy]
6
6
 
7
+ # GET /app/followings/new?uri={uri}
8
+ def new
9
+ # Find actor (and fetch if necessary)
10
+ actor = Actor.find_or_create_by_federation_url(params[:uri])
11
+ # Redirect to local profile page which will have a follow button on it
12
+ redirect_to federails.client_actor_url(actor)
13
+ end
14
+
7
15
  # PUT /app/followings/:id/accept
8
16
  # PUT /app/followings/:id/accept.json
9
17
  def accept
@@ -62,7 +70,7 @@ module Federails
62
70
 
63
71
  # Use callbacks to share common setup or constraints between actions.
64
72
  def set_following
65
- @following = Following.find(params[:id])
73
+ @following = Following.find_param(params[:id])
66
74
  authorize @following, policy_class: Federails::Client::FollowingPolicy
67
75
  end
68
76
 
@@ -8,7 +8,7 @@ module Federails
8
8
  # GET /federation/activities
9
9
  # GET /federation/actors/1/outbox.json
10
10
  def outbox
11
- @actor = Actor.find(params[:actor_id])
11
+ @actor = Actor.find_param(params[:actor_id])
12
12
  @activities = policy_scope(Federails::Activity, policy_scope_class: Federails::Server::ActivityPolicy::Scope).where(actor: @actor).order(created_at: :desc)
13
13
  @total_activities = @activities.count
14
14
  @activities = @activities.page(params[:page])
@@ -20,12 +20,12 @@ module Federails
20
20
  # POST /federation/actors/1/inbox
21
21
  def create
22
22
  payload = payload_from_params
23
- return render json: {}, status: :unprocessable_entity unless payload
23
+ return head :unprocessable_entity unless payload
24
24
 
25
25
  if Fediverse::Inbox.dispatch_request(payload)
26
- render json: {}, status: :created
26
+ head :created
27
27
  else
28
- render json: {}, status: :unprocessable_entity
28
+ head :unprocessable_entity
29
29
  end
30
30
  end
31
31
 
@@ -33,7 +33,7 @@ module Federails
33
33
 
34
34
  # Use callbacks to share common setup or constraints between actions.
35
35
  def set_activity
36
- @activity = Activity.find_by!(actor_id: params[:actor_id], id: params[:id])
36
+ @activity = Actor.find_param(params[:actor_id]).activities.find_param(params[:id])
37
37
  end
38
38
 
39
39
  # Only allow a list of trusted parameters through.
@@ -21,7 +21,7 @@ module Federails
21
21
 
22
22
  # Use callbacks to share common setup or constraints between actions.
23
23
  def set_actor
24
- @actor = Actor.find(params[:id])
24
+ @actor = Actor.find_param(params[:id])
25
25
  authorize @actor, policy_class: Federails::Server::ActorPolicy
26
26
  end
27
27
 
@@ -10,7 +10,8 @@ module Federails
10
10
 
11
11
  # Use callbacks to share common setup or constraints between actions.
12
12
  def set_following
13
- @following = Following.find_by!(actor_id: params[:actor_id], id: params[:id])
13
+ actor = Actor.find_param(params[:actor_id])
14
+ @following = Following.find_by!(actor: actor, uuid: params[:id])
14
15
  authorize @following, policy_class: Federails::Server::FollowingPolicy
15
16
  end
16
17
  end
@@ -2,7 +2,7 @@ module Federails
2
2
  module Server
3
3
  class NodeinfoController < ServerController
4
4
  def index
5
- render formats: [:json]
5
+ render formats: [:nodeinfo]
6
6
  end
7
7
 
8
8
  def show # rubocop:todo Metrics/AbcSize
@@ -15,7 +15,7 @@ module Federails
15
15
  @active_month += model.where(created_at: ((30.days.ago)...Time.current)).count
16
16
  @active_halfyear += model.where(created_at: ((180.days.ago)...Time.current)).count
17
17
  end
18
- render formats: [:json]
18
+ render formats: [:nodeinfo]
19
19
  end
20
20
  end
21
21
  end
@@ -15,11 +15,11 @@ module Federails
15
15
  end
16
16
  raise ActiveRecord::RecordNotFound if @user.nil?
17
17
 
18
- render formats: [:json]
18
+ render formats: [:jrd]
19
19
  end
20
20
 
21
21
  def host_meta
22
- render content_type: 'application/xrd+xml', formats: [:xml]
22
+ render formats: [:xrd]
23
23
  end
24
24
 
25
25
  # TODO: complete missing endpoints
@@ -1,4 +1,12 @@
1
1
  module Federails
2
2
  module ApplicationHelper
3
+ def remote_follow_url
4
+ method_name = Federails.configuration.remote_follow_url_method.to_s
5
+ if method_name.starts_with? 'federails.'
6
+ send(method_name.gsub('federails.', ''))
7
+ else
8
+ Rails.application.routes.url_helpers.send(method_name)
9
+ end
10
+ end
3
11
  end
4
12
  end
@@ -2,7 +2,18 @@ module Federails
2
2
  module Entity
3
3
  extend ActiveSupport::Concern
4
4
 
5
- included do
5
+ included do # rubocop:todo Metrics/BlockLength
6
+ include ActiveSupport::Callbacks
7
+ define_callbacks :followed
8
+
9
+ # Define a method that will be called after the entity receives a follow request
10
+ # @param method [Symbol] The name of the method to call, or a block that will be called directly
11
+ # @example
12
+ # after_followed :accept_follow
13
+ def self.after_followed(method)
14
+ set_callback :followed, :after, method
15
+ end
16
+
6
17
  has_one :actor, class_name: 'Federails::Actor', as: :entity, dependent: :destroy
7
18
 
8
19
  after_create :create_actor
@@ -0,0 +1,35 @@
1
+ module Federails
2
+ module HasUuid
3
+ extend ActiveSupport::Concern
4
+
5
+ included do
6
+ before_validation :generate_uuid
7
+ validates :uuid, presence: true, uniqueness: true
8
+
9
+ def self.find_param(param)
10
+ find_by!(uuid: param)
11
+ end
12
+ end
13
+
14
+ def to_param
15
+ uuid
16
+ end
17
+
18
+ # Override UUID accessor to provide lazy initialization of UUIDs for old data
19
+ def uuid
20
+ if self[:uuid].blank?
21
+ generate_uuid
22
+ save!
23
+ end
24
+ self[:uuid]
25
+ end
26
+
27
+ private
28
+
29
+ def generate_uuid
30
+ return if self[:uuid].present?
31
+
32
+ (self.uuid = SecureRandom.uuid) while self[:uuid].blank? || self.class.exists?(uuid: self[:uuid])
33
+ end
34
+ end
35
+ end
@@ -1,5 +1,7 @@
1
1
  module Federails
2
2
  class Activity < ApplicationRecord
3
+ include Federails::HasUuid
4
+
3
5
  belongs_to :entity, polymorphic: true
4
6
  belongs_to :actor
5
7
 
@@ -13,22 +15,15 @@ module Federails
13
15
 
14
16
  after_create_commit :post_to_inboxes
15
17
 
16
- def recipients # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity
18
+ def recipients
17
19
  return [] unless actor.local?
18
20
 
19
- actors = []
20
- case action
21
- when 'Create'
22
- actors.push(entity.target_actor) if entity_type == 'Federails::Following'
23
- # FIXME: Move this to dummy, somehow
24
- actors.push(*actor.followers) if entity_type == 'Note'
25
- when 'Accept'
26
- actors.push(entity.actor) if entity_type == 'Federails::Following'
27
- when 'Undo'
28
- actors.push(entity.target_actor) if entity_type == 'Federails::Following'
21
+ case entity_type
22
+ when 'Federails::Following'
23
+ [(action == 'Accept' ? entity.actor : entity.target_actor)]
24
+ else
25
+ actor.followers
29
26
  end
30
-
31
- actors
32
27
  end
33
28
 
34
29
  private
@@ -3,6 +3,8 @@ require 'fediverse/webfinger'
3
3
 
4
4
  module Federails
5
5
  class Actor < ApplicationRecord # rubocop:disable Metrics/ClassLength
6
+ include Federails::HasUuid
7
+
6
8
  validates :federated_url, presence: { unless: :entity }, uniqueness: { unless: :entity }
7
9
  validates :username, presence: { unless: :entity }
8
10
  validates :server, presence: { unless: :entity }
@@ -112,7 +114,7 @@ module Federails
112
114
 
113
115
  def find_by_federation_url(federated_url)
114
116
  local_route = Utils::Host.local_route federated_url
115
- return find local_route[:id] if local_route && local_route[:controller] == 'federails/server/actors' && local_route[:action] == 'show'
117
+ return find_param(local_route[:id]) if local_route && local_route[:controller] == 'federails/server/actors' && local_route[:action] == 'show'
116
118
 
117
119
  actor = find_by federated_url: federated_url
118
120
  return actor if actor
@@ -148,5 +150,40 @@ module Federails
148
150
  end
149
151
  end
150
152
  end
153
+
154
+ def public_key
155
+ ensure_key_pair_exists!
156
+ self[:public_key]
157
+ end
158
+
159
+ def private_key
160
+ ensure_key_pair_exists!
161
+ self[:private_key]
162
+ end
163
+
164
+ def key_id
165
+ "#{federated_url}#main-key"
166
+ end
167
+
168
+ private
169
+
170
+ def ensure_key_pair_exists!
171
+ return if self[:private_key].present? || !local?
172
+
173
+ update!(generate_key_pair)
174
+ end
175
+
176
+ def generate_key_pair
177
+ rsa_key = OpenSSL::PKey::RSA.new 2048
178
+ cipher = OpenSSL::Cipher.new('AES-128-CBC')
179
+ {
180
+ private_key: if Rails.application.credentials.secret_key_base
181
+ rsa_key.to_pem(cipher, Rails.application.credentials.secret_key_base)
182
+ else
183
+ rsa_key.to_pem
184
+ end,
185
+ public_key: rsa_key.public_key.to_pem,
186
+ }
187
+ end
151
188
  end
152
189
  end
@@ -1,5 +1,7 @@
1
1
  module Federails
2
2
  class Following < ApplicationRecord
3
+ include Federails::HasUuid
4
+
3
5
  enum status: { pending: 0, accepted: 1 }
4
6
 
5
7
  validates :target_actor_id, uniqueness: { scope: [:actor_id, :target_actor_id] }
@@ -9,6 +11,7 @@ module Federails
9
11
  # FIXME: Handle this with something like undelete
10
12
  has_many :activities, as: :entity, dependent: :destroy
11
13
 
14
+ after_create :after_follow
12
15
  after_create :create_activity
13
16
  after_destroy :destroy_activity
14
17
 
@@ -32,6 +35,12 @@ module Federails
32
35
 
33
36
  private
34
37
 
38
+ def after_follow
39
+ target_actor&.entity&.run_callbacks :followed, :after do
40
+ self
41
+ end
42
+ end
43
+
35
44
  def create_activity
36
45
  Activity.create! actor: actor, action: 'Create', entity: self
37
46
  end
@@ -61,7 +61,7 @@
61
61
  <p>
62
62
  <b>Home page:</b>
63
63
  <% if @actor.local? && Federails::Configuration.user_profile_url_method %>
64
- <%= link_to @actor.send(Federails::Configuration.user_username_field), send(Federails::Configuration.user_profile_url_method, @actor.user) %>
64
+ <%= link_to @actor.send(Federails::Configuration.user_username_field), Rails.application.routes.url_helpers.send(Federails::Configuration.user_profile_url_method, @actor.user) %>
65
65
  <% elsif @actor.profile_url %>
66
66
  <%= link_to @actor.name, @actor.profile_url %>
67
67
  <% else %>
@@ -6,4 +6,9 @@ json.type activity.action
6
6
  json.actor activity.actor.federated_url
7
7
  json.to ['https://www.w3.org/ns/activitystreams#Public']
8
8
  json.cc [activity.actor.followers_url]
9
- json.object activity.entity.federated_url
9
+
10
+ if activity.entity.respond_to? :to_activitypub_object
11
+ json.object activity.entity.to_activitypub_object
12
+ elsif activity.entity.respond_to? :federated_url
13
+ json.object activity.entity.federated_url
14
+ end
@@ -1,4 +1,7 @@
1
- json.set! '@context', 'https://www.w3.org/ns/activitystreams'
1
+ json.set! '@context', [
2
+ 'https://www.w3.org/ns/activitystreams',
3
+ 'https://w3id.org/security/v1',
4
+ ]
2
5
 
3
6
  json.id actor.federated_url
4
7
  json.name actor.name
@@ -9,3 +12,10 @@ json.outbox actor.outbox_url
9
12
  json.followers actor.followers_url
10
13
  json.following actor.followings_url
11
14
  json.url actor.profile_url
15
+ if actor.public_key
16
+ json.publicKey do
17
+ json.id actor.key_id
18
+ json.owner actor.federated_url
19
+ json.publicKeyPem actor.public_key
20
+ end
21
+ end
@@ -4,7 +4,7 @@ links = [
4
4
  # Federation actor URL
5
5
  {
6
6
  rel: 'self',
7
- type: 'application/activity+json',
7
+ type: Mime[:activitypub].to_s,
8
8
  href: @user.actor.federated_url,
9
9
  },
10
10
  ]
@@ -17,4 +17,8 @@ if @user.actor.profile_url
17
17
  href: @user.actor.profile_url
18
18
  end
19
19
 
20
+ # Remote following
21
+ links.push rel: 'http://ostatus.org/schema/1.0/subscribe',
22
+ template: "#{remote_follow_url}?uri={uri}"
23
+
20
24
  json.links links
@@ -0,0 +1,21 @@
1
+ # Webfinger: https://datatracker.ietf.org/doc/html/rfc7033
2
+ Mime::Type.register 'application/jrd+json', :jrd
3
+ Mime::Type.register 'application/xrd+xml', :xrd
4
+
5
+ # ActivityPub: https://www.w3.org/TR/activitypub/#retrieving-objects
6
+ Mime::Type.register 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"', :activitypub, ['application/activity+json', 'application/json']
7
+
8
+ # Nodeinfo: https://github.com/jhass/nodeinfo/blob/main/PROTOCOL.md#retrieval
9
+ Mime::Type.register 'application/json; profile="http://nodeinfo.diaspora.software/ns/schema/2.0#"', :nodeinfo
10
+
11
+ # Get current request parsers. Apparently we need to do it this way and can't add in-place, see
12
+ # https://api.rubyonrails.org/classes/ActionDispatch/Http/Parameters/ClassMethods.html#method-i-parameter_parsers-3D
13
+ parsers = ActionDispatch::Request.parameter_parsers
14
+ # Copy the default JSON parsing for JSON types
15
+ [:jrd, :activitypub, :nodeinfo].each do |mime_type|
16
+ parsers[Mime[mime_type].symbol] = parsers[:json]
17
+ end
18
+ # XRD just needs a simple XML parser
19
+ parsers[Mime[:xrd].symbol] = ->(raw_post) { Hash.from_xml(raw_post) || {} }
20
+ # Store updated parsers
21
+ ActionDispatch::Request.parameter_parsers = parsers
data/config/routes.rb CHANGED
@@ -18,7 +18,7 @@ Federails::Engine.routes.draw do
18
18
  resources :activities, only: [:index]
19
19
  end
20
20
  get :feed, to: 'activities#feed'
21
- resources :followings, only: [:create, :destroy] do
21
+ resources :followings, only: [:new, :create, :destroy] do
22
22
  collection do
23
23
  post :follow, to: 'followings#follow'
24
24
  end
@@ -0,0 +1,13 @@
1
+ class AddUuids < ActiveRecord::Migration[7.0]
2
+ def change
3
+ [
4
+ :federails_actors,
5
+ :federails_activities,
6
+ :federails_followings,
7
+ ].each do |table|
8
+ change_table table do |t|
9
+ t.string :uuid, default: nil, index: { unique: true }
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,8 @@
1
+ class AddKeypairToActors < ActiveRecord::Migration[7.0]
2
+ def change
3
+ change_table :federails_actors do |t|
4
+ t.text :public_key
5
+ t.text :private_key
6
+ end
7
+ end
8
+ end
@@ -42,6 +42,10 @@ module Federails
42
42
  mattr_accessor :client_routes_path
43
43
  @@client_routes_path = :app
44
44
 
45
+ # Route method for remote-following requests
46
+ mattr_accessor :remote_follow_url_method
47
+ @@remote_follow_url_method = 'federails.new_client_following_url'
48
+
45
49
  # Method to use for links to user profiles
46
50
  # @deprecated Set profile_url_method option on acts_as_federails_actor instead
47
51
  mattr_accessor :user_profile_url_method
@@ -1,3 +1,3 @@
1
1
  module Federails
2
- VERSION = '0.1.0'.freeze
2
+ VERSION = '0.2.0'.freeze
3
3
  end
data/lib/federails.rb CHANGED
@@ -27,10 +27,11 @@ module Federails
27
27
  :user_class, # @deprecated
28
28
  :server_routes_path,
29
29
  :client_routes_path,
30
+ :remote_follow_url_method,
30
31
  :user_profile_url_method, # @deprecated
31
32
  :user_name_field, # @deprecated
32
33
  :user_username_field, # @deprecated
33
- ].each { |key| Configuration.send :"#{key}=", config[key] }
34
+ ].each { |key| Configuration.send :"#{key}=", config[key] if config.key?(key) }
34
35
  end
35
36
  end
36
37
  # rubocop:enable Style/ClassVars
@@ -1,21 +1,60 @@
1
+ require 'fediverse/signature'
2
+
1
3
  module Fediverse
2
4
  class Notifier
3
5
  class << self
4
6
  def post_to_inboxes(activity)
5
7
  actors = activity.recipients
6
-
7
8
  Rails.logger.debug('Nobody to notice') && return if actors.count.zero?
8
9
 
9
- message = Federails::ApplicationController.renderer.new.render(
10
+ message = payload(activity)
11
+ actors.each do |recipient|
12
+ Rails.logger.debug { "Sending activity ##{activity.id} to #{recipient.inbox_url}" }
13
+ post_to_inbox(to: recipient, message: message, from: activity.actor)
14
+ end
15
+ end
16
+
17
+ private
18
+
19
+ def payload(activity)
20
+ Federails::ApplicationController.renderer.new.render(
10
21
  template: 'federails/server/activities/show',
11
22
  assigns: { activity: activity },
12
23
  format: :json
13
24
  )
14
- actors.each do |actor|
15
- Rails.logger.debug { "Sending activity ##{activity.id} to #{actor.inbox_url}" }
16
- Faraday.post actor.inbox_url, message, 'Content-Type' => 'application/json', 'Accept' => 'application/json'
25
+ end
26
+
27
+ def post_to_inbox(to:, message:, from: nil)
28
+ conn = Faraday.default_connection
29
+ conn.builder.build_response(
30
+ conn,
31
+ signed_request(to: to, message: message, from: from)
32
+ )
33
+ end
34
+
35
+ def signed_request(to:, message:, from:)
36
+ req = request(to: to, message: message)
37
+ req.headers['Signature'] = Fediverse::Signature.sign(sender: from, request: req) if from
38
+ req
39
+ end
40
+
41
+ def request(to:, message:) # rubocop:todo Metrics/AbcSize
42
+ Faraday.default_connection.build_request(:post) do |req|
43
+ req.url to.inbox_url
44
+ req.body = message
45
+ req.headers['Content-Type'] = Mime[:activitypub].to_s
46
+ req.headers['Accept'] = Mime[:activitypub].to_s
47
+ req.headers['Host'] = URI.parse(to.inbox_url).host
48
+ req.headers['Date'] = Time.now.utc.httpdate
49
+ req.headers['Digest'] = digest(message)
17
50
  end
18
51
  end
52
+
53
+ def digest(message)
54
+ "SHA-256=#{Base64.strict_encode64(
55
+ OpenSSL::Digest.new('SHA256').digest(message)
56
+ )}"
57
+ end
19
58
  end
20
59
  end
21
60
  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
@@ -30,28 +30,52 @@ module Fediverse
30
30
 
31
31
  # Returns actor id
32
32
  def webfinger(username, domain)
33
- scheme = Federails.configuration.force_ssl ? 'https' : 'http'
34
- json = get_json "#{scheme}://#{domain}/.well-known/webfinger", resource: "acct:#{username}@#{domain}"
33
+ json = webfinger_response(username, domain)
35
34
  link = json['links'].find { |l| l['type'] == 'application/activity+json' }
36
35
 
37
36
  link['href'] if link
38
37
  end
39
38
 
39
+ # Returns remote follow link template, or complete link if actor_url is provided
40
+ def remote_follow_url(username, domain, actor_url: nil)
41
+ json = webfinger_response(username, domain)
42
+ link = json['links'].find { |l| l['rel'] == 'http://ostatus.org/schema/1.0/subscribe' }
43
+ return nil if link&.dig('template').nil?
44
+
45
+ if actor_url
46
+ link['template'].gsub('{uri}', CGI.escape(actor_url))
47
+ else
48
+ link['template']
49
+ end
50
+ end
51
+
40
52
  private
41
53
 
54
+ def webfinger_response(username, domain)
55
+ scheme = Federails.configuration.force_ssl ? 'https' : 'http'
56
+ get_json "#{scheme}://#{domain}/.well-known/webfinger", resource: "acct:#{username}@#{domain}"
57
+ end
58
+
59
+ def server_and_port(id)
60
+ uri = URI.parse id
61
+ if uri.port && [80, 443].exclude?(uri.port)
62
+ "#{uri.host}:#{uri.port}"
63
+ else
64
+ uri.host
65
+ end
66
+ end
67
+
42
68
  def webfinger_to_actor(data)
43
- uri = URI.parse data['id']
44
- server = uri.host
45
- server += ":#{uri.port}" if uri.port && [80, 443].exclude?(uri.port)
46
69
  Federails::Actor.new federated_url: data['id'],
47
70
  username: data['preferredUsername'],
48
71
  name: data['name'],
49
- server: server,
72
+ server: server_and_port(data['id']),
50
73
  inbox_url: data['inbox'],
51
74
  outbox_url: data['outbox'],
52
75
  followers_url: data['followers'],
53
76
  followings_url: data['following'],
54
- profile_url: data['url']
77
+ profile_url: data['url'],
78
+ public_key: data.dig('publicKey', 'publicKeyPem')
55
79
  end
56
80
 
57
81
  def get_json(url, payload = {})
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: federails
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Manuel Tancoigne
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2024-09-04 00:00:00.000000000 Z
11
+ date: 2024-10-13 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: faraday
@@ -134,6 +134,7 @@ files:
134
134
  - app/jobs/federails/notify_inbox_job.rb
135
135
  - app/mailers/federails/application_mailer.rb
136
136
  - app/models/concerns/federails/entity.rb
137
+ - app/models/concerns/federails/has_uuid.rb
137
138
  - app/models/federails/activity.rb
138
139
  - app/models/federails/actor.rb
139
140
  - app/models/federails/application_record.rb
@@ -166,24 +167,27 @@ files:
166
167
  - app/views/federails/client/followings/index.json.jbuilder
167
168
  - app/views/federails/client/followings/show.html.erb
168
169
  - app/views/federails/client/followings/show.json.jbuilder
169
- - app/views/federails/server/activities/_activity.json.jbuilder
170
- - app/views/federails/server/activities/outbox.json.jbuilder
171
- - app/views/federails/server/activities/show.json.jbuilder
172
- - app/views/federails/server/actors/_actor.json.jbuilder
173
- - app/views/federails/server/actors/followers.json.jbuilder
174
- - app/views/federails/server/actors/following.json.jbuilder
175
- - app/views/federails/server/actors/show.json.jbuilder
176
- - app/views/federails/server/followings/_following.json.jbuilder
177
- - app/views/federails/server/followings/show.json.jbuilder
178
- - app/views/federails/server/nodeinfo/index.json.jbuilder
179
- - app/views/federails/server/nodeinfo/show.json.jbuilder
180
- - app/views/federails/server/web_finger/find.json.jbuilder
181
- - app/views/federails/server/web_finger/host_meta.xml.erb
170
+ - app/views/federails/server/activities/_activity.activitypub.jbuilder
171
+ - app/views/federails/server/activities/outbox.activitypub.jbuilder
172
+ - app/views/federails/server/activities/show.activitypub.jbuilder
173
+ - app/views/federails/server/actors/_actor.activitypub.jbuilder
174
+ - app/views/federails/server/actors/followers.activitypub.jbuilder
175
+ - app/views/federails/server/actors/following.activitypub.jbuilder
176
+ - app/views/federails/server/actors/show.activitypub.jbuilder
177
+ - app/views/federails/server/followings/_following.activitypub.jbuilder
178
+ - app/views/federails/server/followings/show.activitypub.jbuilder
179
+ - app/views/federails/server/nodeinfo/index.nodeinfo.jbuilder
180
+ - app/views/federails/server/nodeinfo/show.nodeinfo.jbuilder
181
+ - app/views/federails/server/web_finger/find.jrd.jbuilder
182
+ - app/views/federails/server/web_finger/host_meta.xrd.erb
183
+ - config/initializers/mime_types.rb
182
184
  - config/routes.rb
183
185
  - db/migrate/20200712133150_create_federails_actors.rb
184
186
  - db/migrate/20200712143127_create_federails_followings.rb
185
187
  - db/migrate/20200712174938_create_federails_activities.rb
186
188
  - db/migrate/20240731145400_change_actor_entity_rel_to_polymorphic.rb
189
+ - db/migrate/20241002094500_add_uuids.rb
190
+ - db/migrate/20241002094501_add_keypair_to_actors.rb
187
191
  - lib/federails.rb
188
192
  - lib/federails/configuration.rb
189
193
  - lib/federails/engine.rb
@@ -192,6 +196,7 @@ files:
192
196
  - lib/fediverse/inbox.rb
193
197
  - lib/fediverse/notifier.rb
194
198
  - lib/fediverse/request.rb
199
+ - lib/fediverse/signature.rb
195
200
  - lib/fediverse/webfinger.rb
196
201
  - lib/generators/federails/install/USAGE
197
202
  - lib/generators/federails/install/install_generator.rb