federails 0.0.1 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
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