federails 0.0.1 → 0.2.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 (87) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +182 -7
  3. data/Rakefile +5 -5
  4. data/app/controllers/federails/application_controller.rb +23 -0
  5. data/app/controllers/federails/client/activities_controller.rb +21 -0
  6. data/app/controllers/federails/client/actors_controller.rb +37 -0
  7. data/app/controllers/federails/client/followings_controller.rb +101 -0
  8. data/app/controllers/federails/server/activities_controller.rb +65 -0
  9. data/app/controllers/federails/server/actors_controller.rb +34 -0
  10. data/app/controllers/federails/server/followings_controller.rb +19 -0
  11. data/app/controllers/federails/server/nodeinfo_controller.rb +22 -0
  12. data/app/controllers/federails/server/server_controller.rb +17 -0
  13. data/app/controllers/federails/server/web_finger_controller.rb +38 -0
  14. data/app/helpers/federails/application_helper.rb +8 -0
  15. data/app/jobs/federails/notify_inbox_job.rb +12 -0
  16. data/app/mailers/federails/application_mailer.rb +2 -2
  17. data/app/models/concerns/federails/entity.rb +57 -0
  18. data/app/models/concerns/federails/has_uuid.rb +35 -0
  19. data/app/models/federails/activity.rb +35 -0
  20. data/app/models/federails/actor.rb +189 -0
  21. data/app/models/federails/following.rb +52 -0
  22. data/app/policies/federails/client/activity_policy.rb +6 -0
  23. data/app/policies/federails/client/actor_policy.rb +15 -0
  24. data/app/policies/federails/client/following_policy.rb +35 -0
  25. data/app/policies/federails/federails_policy.rb +59 -0
  26. data/app/policies/federails/server/activity_policy.rb +6 -0
  27. data/app/policies/federails/server/actor_policy.rb +23 -0
  28. data/app/policies/federails/server/following_policy.rb +6 -0
  29. data/app/views/federails/client/activities/_activity.html.erb +5 -0
  30. data/app/views/federails/client/activities/_activity.json.jbuilder +1 -0
  31. data/app/views/federails/client/activities/_index.json.jbuilder +1 -0
  32. data/app/views/federails/client/activities/feed.html.erb +4 -0
  33. data/app/views/federails/client/activities/feed.json.jbuilder +1 -0
  34. data/app/views/federails/client/activities/index.html.erb +5 -0
  35. data/app/views/federails/client/activities/index.json.jbuilder +1 -0
  36. data/app/views/federails/client/actors/_actor.json.jbuilder +14 -0
  37. data/app/views/federails/client/actors/_lookup_form.html.erb +5 -0
  38. data/app/views/federails/client/actors/index.html.erb +24 -0
  39. data/app/views/federails/client/actors/index.json.jbuilder +1 -0
  40. data/app/views/federails/client/actors/show.html.erb +100 -0
  41. data/app/views/federails/client/actors/show.json.jbuilder +1 -0
  42. data/app/views/federails/client/followings/_follow.html.erb +4 -0
  43. data/app/views/federails/client/followings/_follower.html.erb +7 -0
  44. data/app/views/federails/client/followings/_following.json.jbuilder +1 -0
  45. data/app/views/federails/client/followings/_form.html.erb +21 -0
  46. data/app/views/federails/client/followings/index.html.erb +29 -0
  47. data/app/views/federails/client/followings/index.json.jbuilder +1 -0
  48. data/app/views/federails/client/followings/show.html.erb +21 -0
  49. data/app/views/federails/client/followings/show.json.jbuilder +1 -0
  50. data/app/views/federails/server/activities/_activity.activitypub.jbuilder +14 -0
  51. data/app/views/federails/server/activities/outbox.activitypub.jbuilder +18 -0
  52. data/app/views/federails/server/activities/show.activitypub.jbuilder +1 -0
  53. data/app/views/federails/server/actors/_actor.activitypub.jbuilder +21 -0
  54. data/app/views/federails/server/actors/followers.activitypub.jbuilder +18 -0
  55. data/app/views/federails/server/actors/following.activitypub.jbuilder +18 -0
  56. data/app/views/federails/server/actors/show.activitypub.jbuilder +1 -0
  57. data/app/views/federails/server/followings/_following.activitypub.jbuilder +7 -0
  58. data/app/views/federails/server/followings/show.activitypub.jbuilder +1 -0
  59. data/app/views/federails/server/nodeinfo/index.nodeinfo.jbuilder +6 -0
  60. data/app/views/federails/server/nodeinfo/show.nodeinfo.jbuilder +19 -0
  61. data/app/views/federails/server/web_finger/find.jrd.jbuilder +24 -0
  62. data/app/views/federails/server/web_finger/host_meta.xrd.erb +5 -0
  63. data/config/initializers/mime_types.rb +21 -0
  64. data/config/routes.rb +43 -0
  65. data/db/migrate/20200712133150_create_federails_actors.rb +24 -0
  66. data/db/migrate/20200712143127_create_federails_followings.rb +14 -0
  67. data/db/migrate/20200712174938_create_federails_activities.rb +11 -0
  68. data/db/migrate/20240731145400_change_actor_entity_rel_to_polymorphic.rb +11 -0
  69. data/db/migrate/20241002094500_add_uuids.rb +13 -0
  70. data/db/migrate/20241002094501_add_keypair_to_actors.rb +8 -0
  71. data/lib/federails/configuration.rb +92 -0
  72. data/lib/federails/engine.rb +6 -0
  73. data/lib/federails/utils/host.rb +54 -0
  74. data/lib/federails/version.rb +1 -1
  75. data/lib/federails.rb +34 -3
  76. data/lib/fediverse/inbox.rb +71 -0
  77. data/lib/fediverse/notifier.rb +60 -0
  78. data/lib/fediverse/request.rb +38 -0
  79. data/lib/fediverse/signature.rb +49 -0
  80. data/lib/fediverse/webfinger.rb +117 -0
  81. data/lib/generators/federails/install/USAGE +9 -0
  82. data/lib/generators/federails/install/install_generator.rb +10 -0
  83. data/lib/generators/federails/install/templates/federails.rb +1 -0
  84. data/lib/generators/federails/install/templates/federails.yml +23 -0
  85. data/lib/tasks/factory_bot.rake +15 -0
  86. metadata +170 -10
  87. data/app/views/layouts/federails/application.html.erb +0 -15
@@ -0,0 +1,54 @@
1
+ module Federails
2
+ module Utils
3
+ class Host
4
+ class << self
5
+ COMMON_PORTS = [80, 443].freeze
6
+
7
+ ##
8
+ # @return [String] Host and port of the current instance
9
+ def localhost
10
+ uri = URI.parse Federails.configuration.site_host
11
+ host_and_port (uri.host || 'localhost'), Federails.configuration.site_port
12
+ end
13
+
14
+ ##
15
+ # Checks if the given URL points somewhere on current instance
16
+ #
17
+ # @param url [String] URL to check
18
+ #
19
+ # @return [true, false]
20
+ def local_url?(url)
21
+ uri = URI.parse url
22
+ host = host_and_port uri.host, uri.port
23
+ localhost == host
24
+ end
25
+
26
+ ##
27
+ # Gets the route on the current instance, or nil
28
+ #
29
+ # @param url [String] URL to check
30
+ #
31
+ # @return [ActionDispatch::Routing::RouteSet, nil] nil when URL do not match a route
32
+ def local_route(url)
33
+ return nil unless local_url? url
34
+
35
+ Rails.application.routes.recognize_path(url)
36
+ rescue ActionController::RoutingError
37
+ nil
38
+ end
39
+
40
+ private
41
+
42
+ def host_and_port(host, port)
43
+ port_string = if port.present? && COMMON_PORTS.exclude?(port)
44
+ ":#{port}"
45
+ else
46
+ ''
47
+ end
48
+
49
+ "#{host}#{port_string}"
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -1,3 +1,3 @@
1
1
  module Federails
2
- VERSION = "0.0.1"
2
+ VERSION = '0.2.0'.freeze
3
3
  end
data/lib/federails.rb CHANGED
@@ -1,6 +1,37 @@
1
- require "federails/version"
2
- require "federails/engine"
1
+ require 'federails/version'
2
+ require 'federails/engine'
3
+ require 'federails/configuration'
3
4
 
5
+ # rubocop:disable Style/ClassVars
4
6
  module Federails
5
- # Your code goes here...
7
+ mattr_reader :configuration
8
+ @@configuration = Configuration
9
+
10
+ # Make factories available
11
+ config.factory_bot.definition_file_paths += [File.expand_path('spec/factories', __dir__)] if defined?(FactoryBotRails)
12
+
13
+ def self.configure
14
+ yield @@configuration
15
+ end
16
+
17
+ def self.config_from(name) # rubocop:disable Metrics/MethodLength
18
+ config = Rails.application.config_for name
19
+ [
20
+ :app_name,
21
+ :app_version,
22
+ :force_ssl,
23
+ :site_host,
24
+ :site_port,
25
+ :enable_discovery,
26
+ :app_layout,
27
+ :user_class, # @deprecated
28
+ :server_routes_path,
29
+ :client_routes_path,
30
+ :remote_follow_url_method,
31
+ :user_profile_url_method, # @deprecated
32
+ :user_name_field, # @deprecated
33
+ :user_username_field, # @deprecated
34
+ ].each { |key| Configuration.send :"#{key}=", config[key] if config.key?(key) }
35
+ end
6
36
  end
37
+ # rubocop:enable Style/ClassVars
@@ -0,0 +1,71 @@
1
+ require 'fediverse/request'
2
+
3
+ module Fediverse
4
+ class Inbox
5
+ class << self
6
+ def dispatch_request(payload)
7
+ case payload['type']
8
+ when 'Create'
9
+ handle_create_request payload
10
+ when 'Follow'
11
+ handle_create_follow_request payload
12
+ when 'Accept'
13
+ handle_accept_request payload
14
+ when 'Undo'
15
+ handle_undo_request payload
16
+ else
17
+ # FIXME: Fails silently
18
+ # raise NotImplementedError
19
+ Rails.logger.debug { "Unhandled activity type: #{payload['type']}" }
20
+ end
21
+ end
22
+
23
+ private
24
+
25
+ def handle_create_request(payload)
26
+ activity = Request.get(payload['object'])
27
+ case activity['type']
28
+ when 'Follow'
29
+ handle_create_follow_request activity
30
+ when 'Note'
31
+ handle_create_note activity
32
+ end
33
+ end
34
+
35
+ def handle_create_follow_request(activity)
36
+ actor = Federails::Actor.find_or_create_by_object activity['actor']
37
+ target_actor = Federails::Actor.find_or_create_by_object activity['object']
38
+
39
+ Federails::Following.create! actor: actor, target_actor: target_actor, federated_url: activity['id']
40
+ end
41
+
42
+ def handle_create_note(activity)
43
+ actor = Federails::Actor.find_or_create_by_object activity['attributedTo']
44
+ Note.create! actor: actor, content: activity['content'], federated_url: activity['id']
45
+ end
46
+
47
+ def handle_accept_request(payload)
48
+ activity = Request.get(payload['object'])
49
+ raise "Can't accept things that are not Follow" unless activity['type'] == 'Follow'
50
+
51
+ actor = Federails::Actor.find_or_create_by_object activity['actor']
52
+ target_actor = Federails::Actor.find_or_create_by_object activity['object']
53
+ raise 'Follow not accepted by target actor but by someone else' if payload['actor'] != target_actor.federated_url
54
+
55
+ follow = Federails::Following.find_by actor: actor, target_actor: target_actor
56
+ follow.accept!
57
+ end
58
+
59
+ def handle_undo_request(payload)
60
+ activity = payload['object']
61
+ raise "Can't undo things that are not Follow" unless activity['type'] == 'Follow'
62
+
63
+ actor = Federails::Actor.find_or_create_by_object activity['actor']
64
+ target_actor = Federails::Actor.find_or_create_by_object activity['object']
65
+
66
+ follow = Federails::Following.find_by actor: actor, target_actor: target_actor
67
+ follow&.destroy
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,60 @@
1
+ require 'fediverse/signature'
2
+
3
+ module Fediverse
4
+ class Notifier
5
+ class << self
6
+ def post_to_inboxes(activity)
7
+ actors = activity.recipients
8
+ Rails.logger.debug('Nobody to notice') && return if actors.count.zero?
9
+
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(
21
+ template: 'federails/server/activities/show',
22
+ assigns: { activity: activity },
23
+ format: :json
24
+ )
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)
50
+ end
51
+ end
52
+
53
+ def digest(message)
54
+ "SHA-256=#{Base64.strict_encode64(
55
+ OpenSSL::Digest.new('SHA256').digest(message)
56
+ )}"
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,38 @@
1
+ require 'json/ld'
2
+
3
+ module Fediverse
4
+ class Request
5
+ BASE_HEADERS = {
6
+ 'Content-Type' => 'application/json',
7
+ 'Accept' => 'application/json',
8
+ }.freeze
9
+
10
+ def initialize(id)
11
+ @id = id
12
+ end
13
+
14
+ def get
15
+ Rails.logger.debug { "GET #{@id}" }
16
+ @response = Faraday.get(@id, nil, BASE_HEADERS)
17
+ response_to_json
18
+ end
19
+
20
+ class << self
21
+ def get(id)
22
+ new(id).get
23
+ end
24
+ end
25
+
26
+ private
27
+
28
+ def response_to_json
29
+ begin
30
+ body = JSON.parse @response.body
31
+ rescue JSON::ParserError
32
+ return
33
+ end
34
+
35
+ JSON::LD::API.compact body, body['@context']
36
+ end
37
+ end
38
+ 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
@@ -0,0 +1,117 @@
1
+ require 'faraday'
2
+ require 'faraday/follow_redirects'
3
+
4
+ require 'federails/utils/host'
5
+
6
+ module Fediverse
7
+ class Webfinger
8
+ class << self
9
+ ACCOUNT_REGEX = /(?<username>[a-z0-9\-_.]+)(?:@(?<domain>.*))?/
10
+
11
+ def split_resource_account(account)
12
+ /\Aacct:#{ACCOUNT_REGEX}\z/io.match account
13
+ end
14
+
15
+ def split_account(account)
16
+ /\A#{ACCOUNT_REGEX}\z/io.match account
17
+ end
18
+
19
+ def local_user?(account)
20
+ account[:username] && (account[:domain].nil? || (account[:domain] == Federails::Utils::Host.localhost))
21
+ end
22
+
23
+ def fetch_actor(username, domain)
24
+ fetch_actor_url webfinger(username, domain)
25
+ end
26
+
27
+ def fetch_actor_url(url)
28
+ webfinger_to_actor get_json url
29
+ end
30
+
31
+ # Returns actor id
32
+ def webfinger(username, domain)
33
+ json = webfinger_response(username, domain)
34
+ link = json['links'].find { |l| l['type'] == 'application/activity+json' }
35
+
36
+ link['href'] if link
37
+ end
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
+
52
+ private
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
+
68
+ def webfinger_to_actor(data)
69
+ Federails::Actor.new federated_url: data['id'],
70
+ username: data['preferredUsername'],
71
+ name: data['name'],
72
+ server: server_and_port(data['id']),
73
+ inbox_url: data['inbox'],
74
+ outbox_url: data['outbox'],
75
+ followers_url: data['followers'],
76
+ followings_url: data['following'],
77
+ profile_url: data['url'],
78
+ public_key: data.dig('publicKey', 'publicKeyPem')
79
+ end
80
+
81
+ def get_json(url, payload = {})
82
+ response = get(url, payload: payload, headers: { accept: 'application/json' })
83
+
84
+ if response.status != 200
85
+ Rails.logger.debug { "Unhandled status code #{response.status} for GET #{url}" }
86
+ raise ActiveRecord::RecordNotFound
87
+ end
88
+
89
+ JSON.parse(response.body)
90
+ rescue JSON::ParserError
91
+ Rails.logger.debug { "Invalid JSON response GET #{url}" }
92
+
93
+ raise ActiveRecord::RecordNotFound
94
+ end
95
+
96
+ # Only perform a GET request and throws an ActiveRecord::RecordNotFound
97
+ # on error.
98
+ # That's "ok-ish"; when an actor is unavailable, whatever the reason is, it's
99
+ # not found...
100
+ def get(url, payload: {}, headers: {})
101
+ connection = Faraday.new url: url, params: payload, headers: headers do |faraday|
102
+ faraday.response :follow_redirects # use Faraday::FollowRedirects::Middleware
103
+ faraday.adapter Faraday.default_adapter
104
+ end
105
+
106
+ begin
107
+ response = connection.get
108
+ rescue Faraday::ConnectionFailed
109
+ Rails.logger.debug { "Failed to reach server for GET #{url}" }
110
+ raise ActiveRecord::RecordNotFound
111
+ end
112
+
113
+ response
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,9 @@
1
+ Description:
2
+ Copies configuration file and initializer
3
+
4
+ Example:
5
+ bin/rails generate federails:install
6
+
7
+ This will create:
8
+ config/federails.yml
9
+ config/initializers/federails.rb
@@ -0,0 +1,10 @@
1
+ module Federails
2
+ class InstallGenerator < Rails::Generators::Base
3
+ source_root File.expand_path('templates', __dir__)
4
+
5
+ def copy_files
6
+ copy_file 'federails.yml', 'config/federails.yml'
7
+ copy_file 'federails.rb', 'config/initializers/federails.rb'
8
+ end
9
+ end
10
+ end
@@ -0,0 +1 @@
1
+ Federails.config_from 'federails'
@@ -0,0 +1,23 @@
1
+ ---
2
+ defaults: &defaults
3
+ app_name:
4
+ app_version:
5
+ force_ssl: false
6
+ site_host: http://localhost
7
+ site_port: 3000
8
+ enable_discovery: true
9
+ app_layout: 'layouts/application'
10
+ server_routes_path: federation
11
+ client_routes_path: app
12
+
13
+ development:
14
+ <<: *defaults
15
+
16
+ test:
17
+ <<: *defaults
18
+ site_port: null
19
+
20
+ production:
21
+ <<: *defaults
22
+ force_ssl: true
23
+ site_port: 443
@@ -0,0 +1,15 @@
1
+ namespace :factory_bot do
2
+ desc 'Verify that all FactoryBot factories are valid'
3
+ task lint: :environment do
4
+ if Rails.env.test?
5
+ conn = ActiveRecord::Base.connection
6
+ conn.transaction do
7
+ FactoryBot.lint traits: true
8
+ raise ActiveRecord::Rollback
9
+ end
10
+ else
11
+ system("bundle exec rake app:factory_bot:lint RAILS_ENV='test'")
12
+ raise if $CHILD_STATUS.exitstatus.nonzero?
13
+ end
14
+ end
15
+ end