fasp_client 0.5.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 (38) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.md +9 -0
  3. data/README.md +119 -0
  4. data/Rakefile +8 -0
  5. data/app/controllers/fasp_client/application_controller.rb +44 -0
  6. data/app/controllers/fasp_client/backfill_requests_controller.rb +21 -0
  7. data/app/controllers/fasp_client/event_subscriptions_controller.rb +31 -0
  8. data/app/controllers/fasp_client/providers_controller.rb +46 -0
  9. data/app/helpers/fasp_client/application_helper.rb +11 -0
  10. data/app/jobs/fasp_client/lifecycle_announcement_job.rb +7 -0
  11. data/app/models/concerns/fasp_client/data_sharing/lifecycle.rb +36 -0
  12. data/app/models/fasp_client/application_record.rb +7 -0
  13. data/app/models/fasp_client/backfill_request.rb +9 -0
  14. data/app/models/fasp_client/event_subscription.rb +28 -0
  15. data/app/models/fasp_client/provider.rb +115 -0
  16. data/app/services/fasp_client/account_search_service.rb +16 -0
  17. data/app/services/fasp_client/capability_activation_service.rb +27 -0
  18. data/app/services/fasp_client/follow_recommendation_service.rb +16 -0
  19. data/app/services/fasp_client/http_request_service.rb +32 -0
  20. data/app/services/fasp_client/provider_info_service.rb +23 -0
  21. data/app/views/fasp_client/providers/edit.html.erb +29 -0
  22. data/app/views/fasp_client/providers/index.html.erb +13 -0
  23. data/config/routes.rb +6 -0
  24. data/db/migrate/20250801150509_create_fasp_client_providers.rb +19 -0
  25. data/db/migrate/20250905153345_create_fasp_client_event_subscriptions.rb +11 -0
  26. data/db/migrate/20250908164011_create_fasp_client_backfill_requests.rb +11 -0
  27. data/lib/fasp_client/configuration.rb +15 -0
  28. data/lib/fasp_client/ed25519_signing_key_coder.rb +11 -0
  29. data/lib/fasp_client/engine.rb +15 -0
  30. data/lib/fasp_client/version.rb +3 -0
  31. data/lib/fasp_client.rb +13 -0
  32. data/lib/generators/fasp_client/install/USAGE +9 -0
  33. data/lib/generators/fasp_client/install/install_generator.rb +13 -0
  34. data/lib/generators/fasp_client/install/templates/fasp_client.rb +16 -0
  35. data/lib/generators/fasp_client/views/USAGE +8 -0
  36. data/lib/generators/fasp_client/views/views_generator.rb +9 -0
  37. data/lib/tasks/fasp_client_tasks.rake +4 -0
  38. metadata +221 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 79b0748608e7c9b2fc0c9cb096954fa37b8270b8467c839175d0799ce809afec
4
+ data.tar.gz: 7f0c2a9d5d9cef162b00edde1711d52d175ce726725db8e79ddea15aa5ace00c
5
+ SHA512:
6
+ metadata.gz: 3c93b8087b5fb053fde2c40cfab4e8fe212c10416a80791aa1bb74935f3e2a689aa0368847f20f06f28bec1d1ea6abd65e316757f4d37b25d6064c0f67293f53
7
+ data.tar.gz: 5261bfcaf33617dc941f57e07f8bf0689be42d48bfcbbb382aa6d50523797e555dad3765c2407907f0d1e72192542e0caca796ddbb0c6bc07aae60c37f0d5646
data/LICENSE.md ADDED
@@ -0,0 +1,9 @@
1
+ # The MIT License
2
+
3
+ Copyright 2025 James Smith <james@floppy.org.uk>
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6
+
7
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,119 @@
1
+ # FaspClient
2
+ A Rails engine that implements the non-provider side of the [Fediverse Auxiliary Service Provider (FASP)](https://fediscovery.org) standard.
3
+
4
+ ## Features
5
+
6
+ * [Base URL discovery](https://github.com/mastodon/fediverse_auxiliary_service_provider_specifications/blob/main/general/v0.1/protocol_basics.md#base-url) ✅
7
+ * [Request integrity](https://github.com/mastodon/fediverse_auxiliary_service_provider_specifications/blob/main/general/v0.1/protocol_basics.md#request-integrity) ✅
8
+ * [Authentication](https://github.com/mastodon/fediverse_auxiliary_service_provider_specifications/blob/main/general/v0.1/protocol_basics.md#authentication) ✅
9
+ * [Provider registration](https://github.com/mastodon/fediverse_auxiliary_service_provider_specifications/blob/main/general/v0.1/registration.md) ✅
10
+ * [Accepting registration requests](https://github.com/mastodon/fediverse_auxiliary_service_provider_specifications/blob/main/general/v0.1/registration.md) ✅
11
+ * [Selecting capabilities](https://github.com/mastodon/fediverse_auxiliary_service_provider_specifications/blob/main/general/v0.1/registration.md#selecting-capabilities) ✅
12
+ * [Fetching FASP information](https://github.com/mastodon/fediverse_auxiliary_service_provider_specifications/blob/main/general/v0.1/provider_info.md) ✅
13
+ * [Capability APIs](https://github.com/mastodon/fediverse_auxiliary_service_provider_specifications/blob/main/general/v0.1/provider_specifications.md) ⏳
14
+
15
+ ## Installation
16
+
17
+ Add to your application's Gemfile:
18
+
19
+ ```ruby
20
+ gem "fasp_client"
21
+ ```
22
+
23
+ Install configuration and run migrations (you'll also want to do this when upgrading).
24
+
25
+ ```shell
26
+ bin/rails generate fasp_client:install && bin/rails db:migrate
27
+ ```
28
+
29
+ You will probably want to customise the view templates used for editing and listing providers.
30
+ You can copy the default views like so:
31
+
32
+ ```shell
33
+ bin/rails generate fasp_client:views
34
+ ```
35
+
36
+ ## Usage
37
+
38
+ Mount the engine in your `routes.rb` file:
39
+
40
+ ```ruby
41
+ mount FaspClient::Engine => "/fasp"
42
+ ```
43
+
44
+ Add the base URL to your nodeinfo metadata:
45
+
46
+ ```ruby
47
+ "faspBaseUrl" => Rails.application.routes.url_helpers.fasp_client_url
48
+ ```
49
+
50
+ If you're using [Federails](https://gitlab.com/experimentslabs/federails), you can add this to the metadata using the configuration option (currently only on the `nodeinfo-metadata` branch):
51
+
52
+ ```ruby
53
+ conf.nodeinfo_metadata = -> do
54
+ {"faspBaseUrl" => Rails.application.routes.url_helpers.fasp_client_url}
55
+ end
56
+ ```
57
+
58
+ Edit `config/initializers/fasp_client.rb` and add customise the template `authenticate` method. It should return true if the current user should be able to access the provider approval/edit pages.
59
+
60
+ Once you've done that, you can sign up to FASP providers using the URL of your site, and you should be able to register, approve, and choose capabilities.
61
+
62
+ ### Capabilities
63
+
64
+ #### account_search
65
+
66
+ Get a list of accounts matching the provided search term, returned as a simple array of account URIs. Provide the optional `limit` parameter to control now many results are returned (default = 20).
67
+
68
+ ```ruby
69
+ my_provider.account_search("mastodon", limit: 5)
70
+ => ["https://mastodon.social/users/Gargron"]
71
+ ```
72
+
73
+ #### data_sharing
74
+
75
+ Automatically shares data from your app to the FASP on demand. To configure one of your models to be included in data sharing, include the appropriate concern and set the configuration:
76
+
77
+ ```ruby
78
+ class MyModel < ApplicationRecord
79
+ include FaspClient::DataSharing::Lifecycle
80
+
81
+ fasp_share_lifecycle category: "account", uri_method: :my_canonical_uri
82
+
83
+ def my_canonical_uri
84
+ # Any method that returns the canonical activitypub URI for this object
85
+ end
86
+ end
87
+ ```
88
+
89
+ Only lifecycle events are currently supported, trending events will be implemented in future. Valid categories are currently `account`, or `content` (see [the spec](https://github.com/mastodon/fediverse_auxiliary_service_provider_specifications/blob/main/discovery/data_sharing/v0.1/data_sharing.md) for details).
90
+
91
+ The actual announcements are then sent to all subscribed providers by a background ActiveJob, using the `default` queue. You can set a specific queue name by adding an optional `queue` parameter to `fasp_share_lifecycle`:
92
+
93
+ ```ruby
94
+ fasp_share_lifecycle category: "account", uri_method: :my_canonical_uri, queue: "fasp_broadcasts"
95
+ ```
96
+
97
+ Only new lifecycle events are currently sent. Whilst backfill requests can be made, they aren't serviced yet (see https://github.com/manyfold3d/fasp_client/issues/25).
98
+
99
+ #### follow_recommendation
100
+
101
+ Get a list of follow recommendations, returned as a simple array of account URIs. The account URI argument is required by the [spec](https://github.com/mastodon/fediverse_auxiliary_service_provider_specifications/blob/main/discovery/follow_recommendation/v0.1/follow_recommendation.md), but won't necessarily affect the results, depending on the server implementation.
102
+
103
+ ```ruby
104
+ my_provider.follow_recommendation(your_account_uri)
105
+ => [
106
+ "https://mastodon.me.uk/users/Floppy",
107
+ "https://mastodon.social/users/Gargron"
108
+ ]
109
+ ```
110
+
111
+ ## License
112
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
113
+
114
+ ## Credits
115
+
116
+ This code was originally written for the [Manyfold](https://github.com/manyfold3d/manyfold) project, which is funded through [NGI0 Entrust](https://nlnet.nl/entrust), a fund established by [NLnet](https://nlnet.nl) with financial support from the European Commission's [Next Generation Internet](https://ngi.eu) program. Learn more at the [NLnet project page](https://nlnet.nl/project/Personal-3D-archive).
117
+
118
+ [<img src="https://nlnet.nl/logo/banner.png" alt="NLnet foundation logo" width="20%" />](https://nlnet.nl)
119
+ [<img src="https://nlnet.nl/image/logos/NGI0_tag.svg" alt="NGI Zero Logo" width="20%" />](https://nlnet.nl/entrust)
data/Rakefile ADDED
@@ -0,0 +1,8 @@
1
+ require "bundler/setup"
2
+
3
+ APP_RAKEFILE = File.expand_path("spec/dummy/Rakefile", __dir__)
4
+ load "rails/tasks/engine.rake"
5
+
6
+ load "rails/tasks/statistics.rake"
7
+
8
+ require "bundler/gem_tasks"
@@ -0,0 +1,44 @@
1
+ module FaspClient
2
+ class ApplicationController < FaspClient::Configuration.instance.controller_base.constantize
3
+ layout FaspClient::Configuration.instance.layout
4
+
5
+ after_action :sign_response
6
+
7
+ def fasp_client_controller?
8
+ true
9
+ end
10
+
11
+ def get_provider
12
+ head :unauthorized and return unless request.headers.key?("Signature-Input")
13
+ m = request.headers["Signature-Input"].match(/keyid=\"([^\"]+)\"/)
14
+ @provider = FaspClient::Provider.find_by(uuid: m[1]) if m
15
+ head :unauthorized if @provider.nil?
16
+ end
17
+
18
+ def verify_request
19
+ head :unauthorized unless @provider.valid_request?(request)
20
+ end
21
+
22
+ private
23
+
24
+ def authenticate
25
+ head :forbidden unless FaspClient::Configuration.instance.authenticate.call(request)
26
+ end
27
+
28
+ def sign_response
29
+ if @provider
30
+ response["date"] = Time.now.utc.to_s
31
+ response["content-digest"] = "sha-256=:"+Digest::SHA256.base64digest(response.body || "")+":"
32
+ Linzer.sign!(
33
+ response,
34
+ key: @provider.local_linzer_key,
35
+ components: %w[@status content-digest],
36
+ label: "sig1",
37
+ params: {
38
+ created: Time.now.utc.to_i
39
+ }
40
+ )
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,21 @@
1
+ module FaspClient
2
+ class BackfillRequestsController < ApplicationController
3
+ wrap_parameters :backfill_request, include: [ :category, :maxCount ]
4
+ before_action :get_provider
5
+ before_action :verify_request
6
+
7
+ def create
8
+ options = params.deep_transform_keys(&:underscore).expect(backfill_request: [ :category, :max_count ])
9
+ req = FaspClient::BackfillRequest.create(options.merge(fasp_client_provider: @provider))
10
+ if req.valid?
11
+ respond_to do |format|
12
+ format.json do
13
+ render json: { "backfillRequest" => { "id" => req.id.to_s } }, status: :created
14
+ end
15
+ end
16
+ else
17
+ head :unprocessable_content
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,31 @@
1
+ module FaspClient
2
+ class EventSubscriptionsController < ApplicationController
3
+ wrap_parameters :event_subscription, include: [ :category, :subscriptionType ]
4
+ before_action :get_provider
5
+ before_action :verify_request
6
+
7
+ def create
8
+ options = params.deep_transform_keys(&:underscore).expect(event_subscription: [ :category, :subscription_type ])
9
+ if options[:category] == "content" && options[:subscription_type] == "trends"
10
+ head :not_implemented
11
+ return
12
+ end
13
+ sub = FaspClient::EventSubscription.create(options.merge(fasp_client_provider: @provider))
14
+ if sub.valid?
15
+ respond_to do |format|
16
+ format.json do
17
+ render json: { "subscription" => { "id" => sub.id.to_s } }, status: :created
18
+ end
19
+ end
20
+ else
21
+ head :unprocessable_content
22
+ end
23
+ end
24
+
25
+ def destroy
26
+ @subscription = @provider.fasp_client_event_subscriptions.find(params[:id])
27
+ @subscription.destroy
28
+ head :no_content
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,46 @@
1
+ module FaspClient
2
+ class ProvidersController < ApplicationController
3
+ wrap_parameters :provider, include: [ :name, :baseUrl, :serverId, :publicKey ]
4
+ protect_from_forgery with: :null_session, only: :create
5
+ before_action :authenticate, except: [ :create ]
6
+ before_action :get_provider, except: [ :create, :index ]
7
+
8
+ def create
9
+ attributes = params.deep_transform_keys(&:underscore).expect(provider: [ :name, :base_url, :server_id, :public_key ])
10
+ @provider = Provider.create(attributes)
11
+ if @provider.valid?
12
+ render json: {
13
+ faspId: @provider.uuid,
14
+ publicKey: Base64.strict_encode64(@provider.ed25519_signing_key.verify_key.to_bytes),
15
+ registrationCompletionUri: edit_provider_url(@provider)
16
+ }, status: :created
17
+ else
18
+ head :bad_request
19
+ end
20
+ end
21
+
22
+ def index
23
+ @providers = Provider.all
24
+ end
25
+
26
+ def update
27
+ provider_params = params.expect(provider: [ :status, enable_capability: [ :id, :version ], disable_capability: [ :id, :version ] ])
28
+ head :bad_request and return unless provider_params
29
+ if provider_params[:enable_capability]
30
+ success = @provider.enable(provider_params[:enable_capability][:id], provider_params[:enable_capability][:version])
31
+ redirect_back_or_to edit_provider_path(@provider), notice: success ? "Enabled" : "Failed"
32
+ elsif provider_params[:disable_capability]
33
+ success = @provider.disable(provider_params[:disable_capability][:id], provider_params[:disable_capability][:version])
34
+ redirect_back_or_to edit_provider_path(@provider), notice: success ? "Disabled" : "Failed"
35
+ elsif @provider.update!(provider_params)
36
+ redirect_back_or_to edit_provider_path(@provider)
37
+ else
38
+ head :bad_request
39
+ end
40
+ end
41
+
42
+ def get_provider
43
+ @provider = Provider.find_by(id: params[:id])
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,11 @@
1
+ module FaspClient
2
+ module ApplicationHelper
3
+ def method_missing(method, *args, &block)
4
+ if main_app.respond_to?(method)
5
+ main_app.send(method, *args)
6
+ else
7
+ super
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,7 @@
1
+ class FaspClient::LifecycleAnnouncementJob < ::ApplicationJob
2
+ def perform(event_type:, category:, uri:)
3
+ FaspClient::EventSubscription.where(category: category, subscription_type: "lifecycle").find_each do |sub|
4
+ sub.announce_lifecycle event_type: event_type, uri: uri
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,36 @@
1
+ module FaspClient
2
+ module DataSharing
3
+ module Lifecycle
4
+ extend ActiveSupport::Concern
5
+
6
+ class_methods do
7
+ def fasp_share_lifecycle(category:, uri_method:, queue: "default")
8
+ self.fasp_uri_method = uri_method
9
+ self.fasp_category = category
10
+ self.fasp_job_queue = queue
11
+ end
12
+ end
13
+
14
+ included do
15
+ cattr_accessor :fasp_category
16
+ cattr_accessor :fasp_uri_method
17
+ cattr_accessor :fasp_job_queue
18
+
19
+ after_commit -> { fasp_emit_lifecycle_announcement "new" }, on: :create
20
+ after_commit -> { fasp_emit_lifecycle_announcement "update" }, on: :update
21
+ before_destroy -> { fasp_emit_lifecycle_announcement "delete" }
22
+ end
23
+
24
+ private
25
+
26
+ def fasp_object_uri
27
+ raise NotImplementedError("set object URI method using `fasp_share_lifecycle`") unless respond_to?(fasp_uri_method)
28
+ send(fasp_uri_method)
29
+ end
30
+
31
+ def fasp_emit_lifecycle_announcement(event_type)
32
+ LifecycleAnnouncementJob.set(queue: fasp_job_queue).perform_later(event_type: event_type, category: fasp_category, uri: fasp_object_uri)
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,7 @@
1
+ module FaspClient
2
+ class ApplicationRecord < ActiveRecord::Base
3
+ CATEGORIES = [ "content", "account" ]
4
+
5
+ self.abstract_class = true
6
+ end
7
+ end
@@ -0,0 +1,9 @@
1
+ module FaspClient
2
+ class BackfillRequest < ApplicationRecord
3
+ belongs_to :fasp_client_provider, class_name: "FaspClient::Provider"
4
+
5
+ validates :fasp_client_provider, presence: true
6
+ validates :category, presence: true, inclusion: FaspClient::ApplicationRecord::CATEGORIES
7
+ validates :max_count, presence: true
8
+ end
9
+ end
@@ -0,0 +1,28 @@
1
+ module FaspClient
2
+ class EventSubscription < ApplicationRecord
3
+ belongs_to :fasp_client_provider, class_name: "FaspClient::Provider"
4
+
5
+ validates :fasp_client_provider, presence: true
6
+ validates :category, presence: true, inclusion: FaspClient::ApplicationRecord::CATEGORIES
7
+ validates :subscription_type, presence: true, inclusion: [ "lifecycle", "trends" ], if: -> { category == "content" }
8
+ validates :subscription_type, presence: true, inclusion: [ "lifecycle" ], unless: -> { category == "content" }
9
+
10
+ def announce_lifecycle(event_type:, uri:)
11
+ return if subscription_type != "lifecycle"
12
+ Rails.logger.debug("Announcing #{subscription_type} event: #{event_type} #{category} #{uri}")
13
+ request = Net::HTTP::Post.new(URI(fasp_client_provider.base_url + "/data_sharing/v0/announcements"))
14
+ request.body = {
15
+ source: {
16
+ subscription: {
17
+ id: id.to_s
18
+ }
19
+ },
20
+ category: category,
21
+ eventType: event_type,
22
+ objectUris: [ uri ]
23
+ }.to_json
24
+ request.content_type = "application/json"
25
+ HttpRequestService.new(provider: fasp_client_provider).execute!(request)
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,115 @@
1
+ require "ed25519"
2
+ require "fasp_client/ed25519_signing_key_coder"
3
+
4
+ module FaspClient
5
+ class Provider < ApplicationRecord
6
+ enum :status, { pending: nil, approved: 1, denied: -1 }, default: :pending, validate: true
7
+
8
+ has_many :fasp_client_event_subscriptions, class_name: "FaspClient::EventSubscription", foreign_key: "fasp_client_provider_id"
9
+ has_many :fasp_client_backfill_requests, class_name: "FaspClient::BackfillRequest", foreign_key: "fasp_client_provider_id"
10
+
11
+ validates :uuid, presence: true
12
+ validates :name, presence: true
13
+ validates :base_url, presence: true
14
+ validates :server_id, presence: true
15
+ validates :public_key, presence: true
16
+ validates :ed25519_signing_key, presence: true
17
+
18
+ serialize :ed25519_signing_key, coder: FaspClient::Ed25519SigningKeyCoder
19
+
20
+ attribute :capabilities, :json, default: []
21
+ attribute :privacy_policy, :json, default: []
22
+
23
+ before_validation on: :create do
24
+ self.uuid ||= SecureRandom.uuid
25
+ self.ed25519_signing_key ||= Ed25519::SigningKey.generate
26
+ end
27
+
28
+ before_save :fetch_provider_info, if: -> { approved? && status_changed? }
29
+
30
+ def verify_key
31
+ Ed25519::VerifyKey.new(Base64.strict_decode64(public_key))
32
+ end
33
+
34
+ def fingerprint
35
+ Digest::SHA256.base64digest(verify_key)
36
+ end
37
+
38
+ def has_capability?(capability, version = nil)
39
+ version ? capability_versions(capability).include?(version) : capability_versions(capability).any?
40
+ end
41
+
42
+ def capability_versions(capability)
43
+ capabilities.filter_map { |it| it["version"] if it["id"] == capability.to_s }
44
+ end
45
+
46
+ def capability_ids
47
+ capabilities.map { |it| it["id"] }.uniq.map(&:to_sym)
48
+ end
49
+
50
+ def fetch_provider_info
51
+ assign_attributes(ProviderInfoService.new(provider: self).to_provider_attributes) if approved?
52
+ end
53
+
54
+ def enable(capability, version)
55
+ return unless has_capability?(capability, version)
56
+ CapabilityActivationService.new(provider: self, capability: capability, version: version).enable!
57
+ end
58
+
59
+ def disable(capability, version)
60
+ return unless has_capability?(capability, version)
61
+ CapabilityActivationService.new(provider: self, capability: capability, version: version).disable!
62
+ end
63
+
64
+ def follow_recommendation(account_uri)
65
+ FollowRecommendationService.new(provider: self).for(account_uri: account_uri)
66
+ end
67
+
68
+ def account_search(query, limit: 20)
69
+ AccountSearchService.new(provider: self).search(query: query, limit: limit)
70
+ end
71
+
72
+ def valid_request?(request)
73
+ HttpRequestService.new(provider: self).verified?(request)
74
+ end
75
+
76
+ def local_linzer_key
77
+ asn1 = OpenSSL::ASN1.Sequence(
78
+ [
79
+ OpenSSL::ASN1::Integer(OpenSSL::BN.new(0)),
80
+ OpenSSL::ASN1.Sequence(
81
+ [
82
+ OpenSSL::ASN1.ObjectId("ED25519")
83
+ ]
84
+ ),
85
+ OpenSSL::ASN1.OctetString(OpenSSL::ASN1.OctetString(ed25519_signing_key.to_bytes).to_der)
86
+ ]
87
+ )
88
+ pem = <<~PEM
89
+ -----BEGIN PRIVATE KEY-----
90
+ #{Base64.strict_encode64(asn1.to_der)}
91
+ -----END PRIVATE KEY-----
92
+ PEM
93
+ Linzer.new_ed25519_key(pem, server_id)
94
+ end
95
+
96
+ def fasp_linzer_key
97
+ asn1 = OpenSSL::ASN1.Sequence(
98
+ [
99
+ OpenSSL::ASN1.Sequence(
100
+ [
101
+ OpenSSL::ASN1.ObjectId("ED25519")
102
+ ]
103
+ ),
104
+ OpenSSL::ASN1.BitString(verify_key.to_bytes)
105
+ ]
106
+ )
107
+ pem = <<~PEM
108
+ -----BEGIN PUBLIC KEY-----
109
+ #{Base64.strict_encode64(asn1.to_der)}
110
+ -----END PUBLIC KEY-----
111
+ PEM
112
+ Linzer.new_ed25519_key(pem, uuid)
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,16 @@
1
+ require "linzer"
2
+
3
+ module FaspClient
4
+ class AccountSearchService
5
+ def initialize(provider:)
6
+ @provider = provider
7
+ end
8
+
9
+ def search(query:, limit: 20)
10
+ response = HttpRequestService.new(provider: @provider).execute!(
11
+ Net::HTTP::Get.new(URI(@provider.base_url + "/account_search/v0/search?limit=#{limit}&term=" + CGI.escape(query)))
12
+ )
13
+ JSON.parse(response.body)
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,27 @@
1
+ module FaspClient
2
+ class CapabilityActivationService
3
+ def initialize(provider:, capability:, version:)
4
+ @provider = provider
5
+ @capability = capability
6
+ @major_version = version.split(".").first
7
+ end
8
+
9
+ def enable!
10
+ HttpRequestService.new(provider: @provider).execute!(
11
+ Net::HTTP::Post.new(uri)
12
+ ).code == "204"
13
+ end
14
+
15
+ def disable!
16
+ HttpRequestService.new(provider: @provider).execute!(
17
+ Net::HTTP::Delete.new(uri)
18
+ ).code == "204"
19
+ end
20
+
21
+ private
22
+
23
+ def uri
24
+ URI("#{@provider.base_url}/capabilities/#{@capability}/#{@major_version}/activation")
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,16 @@
1
+ require "linzer"
2
+
3
+ module FaspClient
4
+ class FollowRecommendationService
5
+ def initialize(provider:)
6
+ @provider = provider
7
+ end
8
+
9
+ def for(account_uri:)
10
+ response = HttpRequestService.new(provider: @provider).execute!(
11
+ Net::HTTP::Get.new(URI(@provider.base_url + "/follow_recommendation/v0/accounts?accountUri=" + CGI.escape(account_uri)))
12
+ )
13
+ JSON.parse(response.body)
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,32 @@
1
+ module FaspClient
2
+ class HttpRequestService
3
+ def initialize(provider:)
4
+ @provider = provider
5
+ end
6
+
7
+ def execute!(request)
8
+ request["date"] = Time.now.utc.to_s
9
+ request["Content-Digest"] = "sha-256=:"+Digest::SHA256.base64digest(request.body || "")+":"
10
+ Linzer.sign!(
11
+ request,
12
+ key: @provider.local_linzer_key,
13
+ components: %w[@method @target-uri content-digest],
14
+ label: "sig1",
15
+ params: {
16
+ created: Time.now.utc.to_i
17
+ }
18
+ )
19
+ response = nil
20
+ Net::HTTP.start request.uri.hostname, request.uri.port, use_ssl: (request.uri.scheme == "https") do |http|
21
+ response = http.request request
22
+ end
23
+ response
24
+ end
25
+
26
+ def verified?(request_or_response)
27
+ Linzer.verify!(request_or_response, key: @provider.fasp_linzer_key)
28
+ rescue Linzer::Error
29
+ false
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,23 @@
1
+ require "linzer"
2
+
3
+ module FaspClient
4
+ class ProviderInfoService
5
+ def initialize(provider:)
6
+ @provider = provider
7
+ end
8
+
9
+ def to_provider_attributes
10
+ response = HttpRequestService.new(provider: @provider).execute!(
11
+ Net::HTTP::Get.new(URI(@provider.base_url + "/provider_info"))
12
+ )
13
+ json ||= JSON.parse(response.body)
14
+ json.slice(
15
+ "capabilities",
16
+ "privacyPolicy",
17
+ "signInUrl",
18
+ "contactEmail",
19
+ "fediverseAccount"
20
+ ).deep_transform_keys(&:underscore).deep_symbolize_keys
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,29 @@
1
+ <%= notice %>
2
+
3
+ <h1>Provider: <%= link_to @provider.name, @provider.base_url %></h1>
4
+
5
+ <div>
6
+ <p>Requested at: <%= @provider.created_at %></p>
7
+ <p>Status: <%= @provider.status %></p>
8
+
9
+ <% if @provider.pending? %>
10
+ <p>
11
+ Fingerprint:
12
+ <code>
13
+ <%= @provider.fingerprint %>
14
+ </code>
15
+ </p>
16
+ <%= button_to "Approve", @provider, method: :patch, params: {provider: {status: "approved"}}, form: {style: "display: inline"} %>
17
+ <%= button_to "Deny", @provider, method: :patch, params: {provider: {status: "denied"}}, form: {style: "display: inline"} %>
18
+ <% else %>
19
+ <table>
20
+ <% @provider.capabilities.each do |cap| %>
21
+ <tr>
22
+ <td><%= cap["id"] %> (<%= cap["version"] %>)</td>
23
+ <td><%= button_to "Enable", @provider, method: :patch, params: {provider: {enable_capability: {id: cap["id"], version: cap["version"]}}}, form: {style: "display: inline"} %></td>
24
+ <td><%= button_to "Disable", @provider, method: :patch, params: {provider: {disable_capability: {id: cap["id"], version: cap["version"]}}}, form: {style: "display: inline"} %></td>
25
+ </tr>
26
+ <% end %>
27
+ </table>
28
+ <% end %>
29
+ </div>
@@ -0,0 +1,13 @@
1
+ <h1>FASP Providers</h1>
2
+
3
+ <% @providers.each do |provider| %>
4
+ <div>
5
+ <h2>
6
+ <%= link_to provider.name, provider.base_url %>
7
+ </h2>
8
+ <p>Requested at: <%= provider.created_at %></p>
9
+ <p>Status: <%= provider.status %></p>
10
+ <%= link_to "Edit", edit_provider_path(provider) %>
11
+ </div>
12
+
13
+ <% end %>
data/config/routes.rb ADDED
@@ -0,0 +1,6 @@
1
+ FaspClient::Engine.routes.draw do
2
+ post "/registration" => "providers#create"
3
+ resources :providers, only: [ :index, :edit, :update ]
4
+ resources :event_subscriptions, only: [ :create, :destroy ], path: "data_sharing/v0/event_subscriptions"
5
+ resources :backfill_requests, only: [ :create ], path: "data_sharing/v0/backfill_requests"
6
+ end
@@ -0,0 +1,19 @@
1
+ class CreateFaspClientProviders < ActiveRecord::Migration[8.0]
2
+ def change
3
+ create_table :fasp_client_providers do |t|
4
+ t.string :uuid
5
+ t.string :name
6
+ t.string :base_url
7
+ t.string :server_id
8
+ t.string :public_key
9
+ t.string :ed25519_signing_key
10
+ t.integer :status
11
+ t.json :capabilities
12
+ t.json :privacy_policy
13
+ t.string :sign_in_url
14
+ t.string :contact_email
15
+ t.string :fediverse_account
16
+ t.timestamps
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,11 @@
1
+ class CreateFaspClientEventSubscriptions < ActiveRecord::Migration[8.0]
2
+ def change
3
+ create_table :fasp_client_event_subscriptions do |t|
4
+ t.references :fasp_client_provider, null: false, foreign_key: true
5
+ t.string :category
6
+ t.string :subscription_type
7
+
8
+ t.timestamps
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,11 @@
1
+ class CreateFaspClientBackfillRequests < ActiveRecord::Migration[8.0]
2
+ def change
3
+ create_table :fasp_client_backfill_requests do |t|
4
+ t.references :fasp_client_provider, null: false, foreign_key: true
5
+ t.string :category
6
+ t.integer :max_count
7
+
8
+ t.timestamps
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,15 @@
1
+ module FaspClient
2
+ class Configuration
3
+ include Singleton
4
+
5
+ attr_accessor :authenticate
6
+ attr_accessor :layout
7
+ attr_accessor :controller_base
8
+
9
+ def initialize
10
+ @authenticate = ->(request) { }
11
+ @layout = "application"
12
+ @controller_base = "ActionController::Base"
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,11 @@
1
+ module FaspClient
2
+ class Ed25519SigningKeyCoder
3
+ def self.dump(value)
4
+ Base64.strict_encode64(value.to_bytes)
5
+ end
6
+
7
+ def self.load(string)
8
+ Ed25519::SigningKey.new(Base64.strict_decode64(string)) if string
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,15 @@
1
+ require "linzer"
2
+
3
+ module FaspClient
4
+ class Engine < ::Rails::Engine
5
+ isolate_namespace FaspClient
6
+
7
+ config.to_prepare do
8
+ # Include main application helpers so we can allow it to override views
9
+ FaspClient::ApplicationController.helper Rails.application.helpers
10
+
11
+ Linzer::Message.register_adapter(ActionDispatch::Request, Linzer::Message::Adapter::Rack::Request)
12
+ Linzer::Message.register_adapter(ActionDispatch::Response, Linzer::Message::Adapter::Rack::Response)
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,3 @@
1
+ module FaspClient
2
+ VERSION = "0.5.0"
3
+ end
@@ -0,0 +1,13 @@
1
+ require "fasp_client/version"
2
+ require "fasp_client/engine"
3
+ require "fasp_client/configuration"
4
+
5
+ module FaspClient
6
+ def self.table_name_prefix
7
+ "fasp_client_"
8
+ end
9
+
10
+ def self.configure
11
+ yield Configuration.instance
12
+ end
13
+ end
@@ -0,0 +1,9 @@
1
+ Description:
2
+ Copies initializer and installs migrations
3
+
4
+ Example:
5
+ bin/rails generate fasp_client:install
6
+
7
+ This will create:
8
+ config/initializers/fasp_client.rb
9
+ db/migrations/*.fasp_client.rb
@@ -0,0 +1,13 @@
1
+ module FaspClient
2
+ class InstallGenerator < Rails::Generators::Base
3
+ source_root File.expand_path("templates", __dir__)
4
+
5
+ def create_initializer_file
6
+ copy_file "fasp_client.rb", Rails.root.join("config", "initializers", "fasp_client.rb")
7
+ end
8
+
9
+ def generate_migrations
10
+ rake "fasp_client:install:migrations"
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,16 @@
1
+ FaspClient.configure do |conf|
2
+ conf.authenticate = ->(request) do
3
+ # Return a truthy value if the current user should be able to access and edit FASP providers, otherwise
4
+ # return falsey. For example:
5
+ #
6
+ # Warden / Devise:
7
+ # request.env["warden"]&.user&.is_administrator?
8
+ end
9
+
10
+ # Configure the layout name that should be used for views; defaults to "application"
11
+ # conf.layout = "application"
12
+
13
+ # For even tighter integration, you might want to use your own ApplicationController as the base
14
+ # class for FaspClient controllers.
15
+ # conf.controller_base = "::ApplicationController"
16
+ end
@@ -0,0 +1,8 @@
1
+ Description:
2
+ Copies fasp_client views to be overridden in main application
3
+
4
+ Example:
5
+ bin/rails generate fasp_client:views
6
+
7
+ This will create:
8
+ app/views/fasp_client/**/*
@@ -0,0 +1,9 @@
1
+ module FaspClient
2
+ class ViewsGenerator < Rails::Generators::Base
3
+ source_root File.expand_path("../../../../app/views", __dir__)
4
+
5
+ def copy_views
6
+ directory "fasp_client", Rails.root.join("app", "views", "fasp_client")
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,4 @@
1
+ # desc "Explaining what the task does"
2
+ # task :fasp_client do
3
+ # # Task goes here
4
+ # end
metadata ADDED
@@ -0,0 +1,221 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: fasp_client
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.5.0
5
+ platform: ruby
6
+ authors:
7
+ - James Smith
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: rails
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '8.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '8.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: ed25519
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '1.4'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '1.4'
40
+ - !ruby/object:Gem::Dependency
41
+ name: linzer
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '0.7'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '0.7'
54
+ - !ruby/object:Gem::Dependency
55
+ name: rubocop-rails-omakase
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '1.1'
61
+ type: :development
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '1.1'
68
+ - !ruby/object:Gem::Dependency
69
+ name: rspec-rails
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: '8.0'
75
+ type: :development
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - "~>"
80
+ - !ruby/object:Gem::Version
81
+ version: '8.0'
82
+ - !ruby/object:Gem::Dependency
83
+ name: byebug
84
+ requirement: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - "~>"
87
+ - !ruby/object:Gem::Version
88
+ version: '12.0'
89
+ type: :development
90
+ prerelease: false
91
+ version_requirements: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - "~>"
94
+ - !ruby/object:Gem::Version
95
+ version: '12.0'
96
+ - !ruby/object:Gem::Dependency
97
+ name: rspec-uuid
98
+ requirement: !ruby/object:Gem::Requirement
99
+ requirements:
100
+ - - "~>"
101
+ - !ruby/object:Gem::Version
102
+ version: '0.6'
103
+ type: :development
104
+ prerelease: false
105
+ version_requirements: !ruby/object:Gem::Requirement
106
+ requirements:
107
+ - - "~>"
108
+ - !ruby/object:Gem::Version
109
+ version: '0.6'
110
+ - !ruby/object:Gem::Dependency
111
+ name: vcr
112
+ requirement: !ruby/object:Gem::Requirement
113
+ requirements:
114
+ - - "~>"
115
+ - !ruby/object:Gem::Version
116
+ version: '6.3'
117
+ type: :development
118
+ prerelease: false
119
+ version_requirements: !ruby/object:Gem::Requirement
120
+ requirements:
121
+ - - "~>"
122
+ - !ruby/object:Gem::Version
123
+ version: '6.3'
124
+ - !ruby/object:Gem::Dependency
125
+ name: webmock
126
+ requirement: !ruby/object:Gem::Requirement
127
+ requirements:
128
+ - - "~>"
129
+ - !ruby/object:Gem::Version
130
+ version: '3.25'
131
+ type: :development
132
+ prerelease: false
133
+ version_requirements: !ruby/object:Gem::Requirement
134
+ requirements:
135
+ - - "~>"
136
+ - !ruby/object:Gem::Version
137
+ version: '3.25'
138
+ - !ruby/object:Gem::Dependency
139
+ name: factory_bot
140
+ requirement: !ruby/object:Gem::Requirement
141
+ requirements:
142
+ - - "~>"
143
+ - !ruby/object:Gem::Version
144
+ version: '6.5'
145
+ type: :development
146
+ prerelease: false
147
+ version_requirements: !ruby/object:Gem::Requirement
148
+ requirements:
149
+ - - "~>"
150
+ - !ruby/object:Gem::Version
151
+ version: '6.5'
152
+ description: A Rails engine that implements the non-provider side of the Fediverse
153
+ Auxiliary Service Provider (FASP) standard.
154
+ email:
155
+ - james@floppy.org.uk
156
+ executables: []
157
+ extensions: []
158
+ extra_rdoc_files: []
159
+ files:
160
+ - LICENSE.md
161
+ - README.md
162
+ - Rakefile
163
+ - app/controllers/fasp_client/application_controller.rb
164
+ - app/controllers/fasp_client/backfill_requests_controller.rb
165
+ - app/controllers/fasp_client/event_subscriptions_controller.rb
166
+ - app/controllers/fasp_client/providers_controller.rb
167
+ - app/helpers/fasp_client/application_helper.rb
168
+ - app/jobs/fasp_client/lifecycle_announcement_job.rb
169
+ - app/models/concerns/fasp_client/data_sharing/lifecycle.rb
170
+ - app/models/fasp_client/application_record.rb
171
+ - app/models/fasp_client/backfill_request.rb
172
+ - app/models/fasp_client/event_subscription.rb
173
+ - app/models/fasp_client/provider.rb
174
+ - app/services/fasp_client/account_search_service.rb
175
+ - app/services/fasp_client/capability_activation_service.rb
176
+ - app/services/fasp_client/follow_recommendation_service.rb
177
+ - app/services/fasp_client/http_request_service.rb
178
+ - app/services/fasp_client/provider_info_service.rb
179
+ - app/views/fasp_client/providers/edit.html.erb
180
+ - app/views/fasp_client/providers/index.html.erb
181
+ - config/routes.rb
182
+ - db/migrate/20250801150509_create_fasp_client_providers.rb
183
+ - db/migrate/20250905153345_create_fasp_client_event_subscriptions.rb
184
+ - db/migrate/20250908164011_create_fasp_client_backfill_requests.rb
185
+ - lib/fasp_client.rb
186
+ - lib/fasp_client/configuration.rb
187
+ - lib/fasp_client/ed25519_signing_key_coder.rb
188
+ - lib/fasp_client/engine.rb
189
+ - lib/fasp_client/version.rb
190
+ - lib/generators/fasp_client/install/USAGE
191
+ - lib/generators/fasp_client/install/install_generator.rb
192
+ - lib/generators/fasp_client/install/templates/fasp_client.rb
193
+ - lib/generators/fasp_client/views/USAGE
194
+ - lib/generators/fasp_client/views/views_generator.rb
195
+ - lib/tasks/fasp_client_tasks.rake
196
+ homepage: https://github.com/manyfold3d/fasp_client
197
+ licenses:
198
+ - MIT
199
+ metadata:
200
+ allowed_push_host: https://rubygems.org
201
+ homepage_uri: https://github.com/manyfold3d/fasp_client
202
+ source_code_uri: https://github.com/manyfold3d/fasp_client
203
+ changelog_uri: https://github.com/manyfold3d/fasp_client/releases
204
+ rdoc_options: []
205
+ require_paths:
206
+ - lib
207
+ required_ruby_version: !ruby/object:Gem::Requirement
208
+ requirements:
209
+ - - ">="
210
+ - !ruby/object:Gem::Version
211
+ version: '0'
212
+ required_rubygems_version: !ruby/object:Gem::Requirement
213
+ requirements:
214
+ - - ">="
215
+ - !ruby/object:Gem::Version
216
+ version: '0'
217
+ requirements: []
218
+ rubygems_version: 3.6.9
219
+ specification_version: 4
220
+ summary: A Rails engine for FASP client apps
221
+ test_files: []