diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..132a47f --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,18 @@ +name: Ruby + +on: [push,pull_request] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.0.0 + - name: Run the default task + run: | + gem install bundler -v 2.2.3 + bundle install + bundle exec rake diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b04a8c8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +/.bundle/ +/.yardoc +/_yardoc/ +/coverage/ +/doc/ +/pkg/ +/spec/reports/ +/tmp/ + +# rspec failure tracking +.rspec_status diff --git a/.rspec b/.rspec new file mode 100644 index 0000000..34c5164 --- /dev/null +++ b/.rspec @@ -0,0 +1,3 @@ +--format documentation +--color +--require spec_helper diff --git a/.rubocop.yml b/.rubocop.yml new file mode 100644 index 0000000..00a72e3 --- /dev/null +++ b/.rubocop.yml @@ -0,0 +1,10 @@ +Style/StringLiterals: + Enabled: true + EnforcedStyle: double_quotes + +Style/StringLiteralsInInterpolation: + Enabled: true + EnforcedStyle: double_quotes + +Layout/LineLength: + Max: 120 diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..baeb205 --- /dev/null +++ b/Gemfile @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +source "https://rubygems.org" + +# Specify your gem's dependencies in activitypub.gemspec +gemspec + +gem "pry" + +gem "webmock" + +gem "sinatra" +gem "puma" + +gem "rake", "~> 13.0" + +gem "rspec", "~> 3.0" + +gem "rubocop", "~> 0.80" diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 0000000..12b554e --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,91 @@ +PATH + remote: . + specs: + activitypub (0.1.0) + +GEM + remote: https://rubygems.org/ + specs: + addressable (2.8.5) + public_suffix (>= 2.0.2, < 6.0) + ast (2.4.2) + coderay (1.1.3) + crack (0.4.5) + rexml + diff-lcs (1.5.0) + hashdiff (1.0.1) + method_source (1.0.0) + mustermann (3.0.0) + ruby2_keywords (~> 0.0.1) + nio4r (2.5.9) + parallel (1.23.0) + parser (3.2.2.3) + ast (~> 2.4.1) + racc + pry (0.14.2) + coderay (~> 1.1) + method_source (~> 1.0) + public_suffix (5.0.3) + puma (6.3.1) + nio4r (~> 2.0) + racc (1.7.1) + rack (2.2.8) + rack-protection (3.1.0) + rack (~> 2.2, >= 2.2.4) + rainbow (3.1.1) + rake (13.0.6) + regexp_parser (2.8.1) + rexml (3.2.6) + rspec (3.12.0) + rspec-core (~> 3.12.0) + rspec-expectations (~> 3.12.0) + rspec-mocks (~> 3.12.0) + rspec-core (3.12.2) + rspec-support (~> 3.12.0) + rspec-expectations (3.12.3) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.12.0) + rspec-mocks (3.12.6) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.12.0) + rspec-support (3.12.1) + rubocop (0.93.1) + parallel (~> 1.10) + parser (>= 2.7.1.5) + rainbow (>= 2.2.2, < 4.0) + regexp_parser (>= 1.8) + rexml + rubocop-ast (>= 0.6.0) + ruby-progressbar (~> 1.7) + unicode-display_width (>= 1.4.0, < 2.0) + rubocop-ast (1.29.0) + parser (>= 3.2.1.0) + ruby-progressbar (1.13.0) + ruby2_keywords (0.0.5) + sinatra (3.1.0) + mustermann (~> 3.0) + rack (~> 2.2, >= 2.2.4) + rack-protection (= 3.1.0) + tilt (~> 2.0) + tilt (2.2.0) + unicode-display_width (1.8.0) + webmock (3.19.1) + addressable (>= 2.8.0) + crack (>= 0.3.2) + hashdiff (>= 0.4.0, < 2.0.0) + +PLATFORMS + arm64-darwin-21 + +DEPENDENCIES + activitypub! + pry + puma + rake (~> 13.0) + rspec (~> 3.0) + rubocop (~> 0.80) + sinatra + webmock + +BUNDLED WITH + 2.2.3 diff --git a/README.md b/README.md new file mode 100644 index 0000000..7a9fedf --- /dev/null +++ b/README.md @@ -0,0 +1,133 @@ +# ActivityPub Ruby Library + +This library provides a simple interface to work with the ActivityPub protocol in Ruby applications. + +## Features +- Create and manage ActivityPub Objects. +- Send and receive Activities using Outbox and Inbox functionality. +- Signature verification for secured interactions. +- Simple Sinatra example to demonstrate usage. + +## Installation + +```bash +gem install activitypub +``` + +Or add it to your Gemfile: + +```ruby +gem 'activitypub' +``` + +Then run `bundle install`. + +## Usage + +### Creating an ActivityPub Object + +```ruby +activity = ActivityPub::Object.new(type: "Note", content: "Hello ActivityPub!") +``` + +### Sending an Activity + +```ruby +outbox = ActivityPub::Outbox.new("https://recipient.com/actors/2/inbox") +outbox.send(activity) +``` + +### Receiving an Activity + +```ruby +inbox = ActivityPub::Inbox.new +received_activity = inbox.receive(request_body) +``` + +### Verifying Signatures + +```ruby +signature = ActivityPub::Signature.new +if signature.verify(received_activity) + puts "Signature is valid!" +else + puts "Signature is invalid!" +end +``` + +## Sinatra Example + +A simple Sinatra app is included to demonstrate the library's functionality. To run it: + +1. Navigate to the `example` directory. +2. Run `bundle install`. +3. Run the app with `ruby app.rb`. + +Visit `http://localhost:4567` to access the example interface. + +## Contributing + +Contributions are welcome! Please submit a pull request or open an issue to discuss any changes or features. + +## License + +This library is released under the MIT License. See `LICENSE` for details. + +## Sinatra app testing: + +To test the Sinatra app that we just set up, you'll need to interact with its endpoints, specifically sending and receiving activities to and from the outbox and inbox respectively. + +Let's first ensure that the Sinatra app is running: + +1. Run the Sinatra application: + ```bash + bundle exec ruby app.rb + ``` + +2. Visit `http://localhost:4567/` in your browser to confirm that the app is running. + +Now, there are a few ways to interact with the application: + +### 1. Using `curl` + +**Sending an Activity to Outbox**: +Let's send a "Note" type activity to our actor's outbox which should forward it to another actor's inbox. + +```bash +curl -X POST -d "inbox_url=http://recipient.com/actors/2/inbox" -d "activity_data={\"type\":\"Note\",\"content\":\"Hello from Sinatra!\"}" http://localhost:4567/actors/1/outbox +``` + +**Receiving an Activity in Inbox**: +You can simulate another actor sending an activity to our actor's inbox: + +```bash +curl -X POST -H "Content-Type: application/json" -d "{\"type\":\"Note\",\"content\":\"Hello to Sinatra from another actor!\"}" http://localhost:4567/actors/1/inbox +``` + +### 2. Using a Web Browser + +You can easily check the root endpoint by navigating to `http://localhost:4567/` in your browser. However, for POST requests like the outbox and inbox interactions, you'll need a tool more suited for the task. + +### 3. Using Postman + +[Postman](https://www.postman.com/) is a popular tool for testing API endpoints. + +1. **Sending an Activity to Outbox**: + - Set the method to `POST`. + - URL: `http://localhost:4567/actors/1/outbox` + - Set the headers: + - `Content-Type: application/x-www-form-urlencoded` + - In the body, select `x-www-form-urlencoded` and add two keys: + - `inbox_url` with the value `http://recipient.com/actors/2/inbox` + - `activity_data` with the value `{"type":"Note","content":"Hello from Sinatra!"}` + - Click on "Send". + +2. **Receiving an Activity in Inbox**: + - Set the method to `POST`. + - URL: `http://localhost:4567/actors/1/inbox` + - Set the headers: + - `Content-Type: application/json` + - In the body, select `raw` and paste in `{"type":"Note","content":"Hello to Sinatra from another actor!"}` + - Click on "Send". + +**Note**: As this example is highly simplified, it doesn't handle potential errors or specific responses you'd expect from a fully-fledged ActivityPub service. Ensure that any production implementation follows the ActivityPub specification closely and manages potential risks. \ No newline at end of file diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..cca7175 --- /dev/null +++ b/Rakefile @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +require "bundler/gem_tasks" +require "rspec/core/rake_task" + +RSpec::Core::RakeTask.new(:spec) + +require "rubocop/rake_task" + +RuboCop::RakeTask.new + +task default: %i[spec rubocop] diff --git a/activitypub.gemspec b/activitypub.gemspec new file mode 100644 index 0000000..71123cc --- /dev/null +++ b/activitypub.gemspec @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +require_relative "lib/activitypub/version" + +Gem::Specification.new do |spec| + spec.name = "activitypub" + spec.version = Activitypub::VERSION + spec.authors = ["Miguel Michelson Martinez"] + spec.email = ["miguelmichelson@gmail.com"] + + spec.summary = "A Ruby library for the ActivityPub protocol." + spec.description = "A comprehensive library for building and parsing ActivityPub content in Ruby." + spec.homepage = "https://github.com/rauversion/activitypub_ruby" + spec.license = "MIT" + + spec.required_ruby_version = Gem::Requirement.new(">= 2.3.0") + + # spec.metadata["allowed_push_host"] = "TODO: Set to 'http://mygemserver.com'" + + spec.metadata["homepage_uri"] = spec.homepage + spec.metadata["source_code_uri"] = spec.homepage # "TODO: Put your gem's public repo URL here." + spec.metadata["changelog_uri"] = spec.homepage # "TODO: Put your gem's CHANGELOG.md URL here." + + # Specify which files should be added to the gem when it is released. + # The `git ls-files -z` loads the files in the RubyGem that have been added into git. + spec.files = Dir.chdir(File.expand_path(__dir__)) do + `git ls-files -z`.split("\x0").reject { |f| f.match(%r{\A(?:test|spec|features)/}) } + end + spec.bindir = "exe" + spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) } + spec.require_paths = ["lib"] + + # Uncomment to register a new dependency of your gem + # spec.add_dependency "example-gem", "~> 1.0" + + # For more information and examples about making a new gem, checkout our + # guide at: https://bundler.io/guides/creating_gem.html +end diff --git a/app.rb b/app.rb new file mode 100644 index 0000000..ca3ae4f --- /dev/null +++ b/app.rb @@ -0,0 +1,52 @@ +require 'sinatra' +require_relative "lib/activitypub" + +# For simplicity, we'll hardcode actor_id and generate a keypair on startup +actor_id = "https://example.com/actors/1" +keypair = ActivityPub::Signature.generate_keypair +outbox = ActivityPub::Outbox.new(actor_id: actor_id, private_key: keypair[:private]) + +post '/actors/1/outbox' do + target_inbox_url = params[:inbox_url] # expect the client to provide target inbox URL + activity_data = params[:activity_data] # and the activity data + + response = outbox.send_activity(target_inbox_url, activity_data) + [response.code.to_i, response.body] +end + +post '/actors/1/inbox' do + # This is where other actors would send activities to our actor + incoming_activity = JSON.parse(params['incoming_activity']) # JSON.parse(request.body.read) + + # For demonstration purposes, we'll just print the activity to the console + puts "Received activity: #{incoming_activity}" + + # Respond with a 200 OK for simplicity + [200, "Activity received"] +end + + + +get '/' do + %( + "ActivityPub Sinatra Example" +

Send an Activity to Outbox

+
+ + +

+ + +

+ +
+


+

Simulate Sending an Activity to our Inbox

+
+ + +

+ +
+ ) +end \ No newline at end of file diff --git a/bin/console b/bin/console new file mode 100755 index 0000000..854dafe --- /dev/null +++ b/bin/console @@ -0,0 +1,15 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require "bundler/setup" +require "activitypub" + +# You can add fixtures and/or initialization code here to make experimenting +# with your gem easier. You can also use a different console, if you like. + +# (If you use this, don't forget to add pry to your Gemfile!) +# require "pry" +# Pry.start + +require "irb" +IRB.start(__FILE__) diff --git a/bin/setup b/bin/setup new file mode 100755 index 0000000..dce67d8 --- /dev/null +++ b/bin/setup @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -euo pipefail +IFS=$'\n\t' +set -vx + +bundle install + +# Do any other automated setup that you need to do here diff --git a/lib/activitypub.rb b/lib/activitypub.rb new file mode 100644 index 0000000..d953fa7 --- /dev/null +++ b/lib/activitypub.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true +require "json" +require 'time' +require 'base64' +require_relative "activitypub/version" +require_relative "activitypub/activity" +require_relative "activitypub/object" +require_relative "activitypub/actor" +require_relative "activitypub/signature" +require_relative "activitypub/inbox" +require_relative "activitypub/outbox" + +module Activitypub + class Error < StandardError; end + # Your code goes here... +end diff --git a/lib/activitypub/activity.rb b/lib/activitypub/activity.rb new file mode 100644 index 0000000..cfbebc8 --- /dev/null +++ b/lib/activitypub/activity.rb @@ -0,0 +1,70 @@ +module ActivityPub + class Activity + # The type of the activity, e.g., 'Create', 'Like', 'Follow', etc. + attr_accessor :type + + # The actor performing the activity. + attr_accessor :actor + + # The object of the activity, e.g., a post, a comment, etc. + attr_accessor :object + + # Any additional properties can go here. + # ... + + def initialize(type:, actor:, object:, **other_attributes) + @type = type + @actor = actor + @object = object + + # Handle other attributes as needed + other_attributes.each do |key, value| + instance_variable_set("@#{key}", value) + end + end + + # Convert the activity into a hash representation. + def to_h + { + '@context': 'https://www.w3.org/ns/activitystreams', + type: @type, + actor: @actor, + object: @object + # Add any other attributes as needed. + # ... + } + end + + # Convert the activity into a JSON representation. + def to_json(*args) + to_h.to_json(*args) + end + + # Load an activity from a hash. + def self.from_h(hash) + new( + type: hash['type'], + actor: hash['actor'], + object: hash['object'] + # Handle any other attributes as needed. + # ... + ) + end + end +end + +#Usage example: + +#ruby +#Copy code +#activity = ActivityPub::Activity.new(type: 'Like', actor: 'https://example.com/users/alice', object: 'https://example.com/posts/1') +#puts activity.to_json + +#loaded_activity = ActivityPub::Activity.from_h(JSON.parse(activity.to_json)) +#puts loaded_activity.type # => "Like" +#Note that this is a basic and illustrative implementation. In a real-world scenario, you would need to handle more attributes, validations, and scenarios specified by the ActivityStreams and ActivityPub specifications. + + + + + diff --git a/lib/activitypub/actor.rb b/lib/activitypub/actor.rb new file mode 100644 index 0000000..02c0f3a --- /dev/null +++ b/lib/activitypub/actor.rb @@ -0,0 +1,55 @@ +# The ActivityPub::Actor represents individual and +# collective entities which perform activities. +# Commonly used actor types include Person, Organization, +# Service, etc. For simplicity, we will focus on the Person +# actor type. + +# Here's a basic representation for the ActivityPub::Actor: + +module ActivityPub + class Actor + attr_accessor :id, :type, :name, :preferred_username, :inbox, :outbox, :followers, :following + + def initialize(id:, type: 'Person', name:, preferred_username:, inbox:, outbox:, followers: nil, following: nil) + @id = id + @type = type + @name = name + @preferred_username = preferred_username + @inbox = inbox + @outbox = outbox + @followers = followers + @following = following + end + + def to_h + { + '@context': 'https://www.w3.org/ns/activitystreams', + id: @id, + type: @type, + name: @name, + preferredUsername: @preferred_username, + inbox: @inbox, + outbox: @outbox, + followers: @followers, + following: @following + }.reject { |_, v| v.nil? } + end + + def to_json(*args) + to_h.to_json(*args) + end + + def self.from_h(hash) + new( + id: hash['id'], + type: hash['type'] || 'Person', + name: hash['name'], + preferred_username: hash['preferredUsername'], + inbox: hash['inbox'], + outbox: hash['outbox'], + followers: hash['followers'], + following: hash['following'] + ) + end + end +end diff --git a/lib/activitypub/inbox.rb b/lib/activitypub/inbox.rb new file mode 100644 index 0000000..a8a13a7 --- /dev/null +++ b/lib/activitypub/inbox.rb @@ -0,0 +1,45 @@ +require 'net/http' +require 'json' + +module ActivityPub + class Inbox + attr_reader :actor_id + + def initialize(actor_id:) + @actor_id = actor_id + end + + def accept_activity(activity_data, headers) + # Fetch the actor's public key + public_key = fetch_actor_public_key(actor_id) + + # Extract the signature from headers + signature = headers["Signature"] + + # Verify the activity's signature + unless ActivityPub::Signature.verify?(activity_data, signature, public_key) + raise "Invalid signature" + end + + # Process activity + process_activity(activity_data) + end + + private + + def fetch_actor_public_key(actor_id) + # Fetch the actor's profile + uri = URI(actor_id) + response = Net::HTTP.get(uri) + profile = JSON.parse(response) + + # This assumes the public key is stored under a 'publicKey' key in the actor's profile. + # This structure may vary based on the implementation details of the server hosting the actor's profile. + profile['publicKey']['publicKeyPem'] + end + + def process_activity(activity_data) + puts "Received activity: #{activity_data}" + end + end +end diff --git a/lib/activitypub/object.rb b/lib/activitypub/object.rb new file mode 100644 index 0000000..3661fbf --- /dev/null +++ b/lib/activitypub/object.rb @@ -0,0 +1,51 @@ +module ActivityPub + class Object + attr_accessor :id, :type, :published, :updated, :content, :attributed_to + + def initialize(id:, type: 'Object', published: nil, updated: nil, content: nil, attributed_to: nil) + @id = id + @type = type + @published = published || Time.now.utc.iso8601 + @updated = updated || Time.now.utc.iso8601 + @content = content + @attributed_to = attributed_to + end + + def to_h + { + '@context': 'https://www.w3.org/ns/activitystreams', + id: @id, + type: @type, + published: @published, + updated: @updated, + content: @content, + attributedTo: @attributed_to + }.reject { |_, v| v.nil? } + end + + def to_json(*args) + to_h.to_json(*args) + end + + def self.from_h(hash) + new( + id: hash['id'], + type: hash['type'] || 'Object', + published: hash['published'], + updated: hash['updated'], + content: hash['content'], + attributed_to: hash['attributedTo'] + ) + end + end +end + + +=begin + obj = ActivityPub::Object.new(id: 'https://example.com/objects/1', content: 'Hello world!') +puts obj.to_json + +loaded_obj = ActivityPub::Object.from_h(JSON.parse(obj.to_json)) +puts loaded_obj.content # => "Hello world!" + +=end \ No newline at end of file diff --git a/lib/activitypub/outbox.rb b/lib/activitypub/outbox.rb new file mode 100644 index 0000000..7486c56 --- /dev/null +++ b/lib/activitypub/outbox.rb @@ -0,0 +1,36 @@ +require 'net/http' + +module ActivityPub + class Outbox + attr_reader :actor_id, :private_key + + def initialize(actor_id:, private_key:) + @actor_id = actor_id + @private_key = private_key + end + + # Simulate sending a new activity to a target actor's inbox + def send_activity(target_inbox_url, activity_data) + # Sign the activity data + signature = ActivityPub::Signature.sign(activity_data, private_key) + + # Send the signed activity to the target actor's inbox + response = post_activity(target_inbox_url, activity_data, signature) + + # Save the activity in the outbox (not implemented here for simplicity, but would be in a real-world scenario) + # save_activity(activity_data) + + response + end + + private + + def post_activity(inbox_url, activity_data, signature) + uri = URI(inbox_url) + http = Net::HTTP.new(uri.host, uri.port) + request = Net::HTTP::Post.new(uri.path, { 'Content-Type' => 'application/ld+json', 'Signature' => signature }) + request.body = activity_data + http.request(request) + end + end +end diff --git a/lib/activitypub/signature.rb b/lib/activitypub/signature.rb new file mode 100644 index 0000000..f43dcbf --- /dev/null +++ b/lib/activitypub/signature.rb @@ -0,0 +1,30 @@ +require 'openssl' + +# Digital signatures are essential in federated networks +# like ActivityPub to ensure data integrity and authenticity. +# A commonly used algorithm for generating these signatures +# in the ActivityPub community is the RSA-SHA256 algorithm. + +module ActivityPub + class Signature + # Generates a new RSA private and public key pair + def self.generate_keypair + key = OpenSSL::PKey::RSA.new(2048) + { private: key.to_pem, public: key.public_key.to_pem } + end + + # Signs data with the given private key + def self.sign(data, private_key_pem) + private_key = OpenSSL::PKey::RSA.new(private_key_pem) + signature = private_key.sign(OpenSSL::Digest::SHA256.new, data) + Base64.encode64(signature).gsub("\n", '') + end + + # Verifies the signature with the provided public key + def self.verify?(data, signature, public_key_pem) + public_key = OpenSSL::PKey::RSA.new(public_key_pem) + signature_decoded = Base64.decode64(signature) + public_key.verify(OpenSSL::Digest::SHA256.new, signature_decoded, data) + end + end +end diff --git a/lib/activitypub/version.rb b/lib/activitypub/version.rb new file mode 100644 index 0000000..afd2cf9 --- /dev/null +++ b/lib/activitypub/version.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +module Activitypub + VERSION = "0.1.0" +end diff --git a/spec/activity_spec.rb b/spec/activity_spec.rb new file mode 100644 index 0000000..8b975a7 --- /dev/null +++ b/spec/activity_spec.rb @@ -0,0 +1,36 @@ +RSpec.describe ActivityPub::Activity do + describe '#initialize' do + subject { described_class.new(type: 'Like', actor: 'https://example.com/users/alice', object: 'https://example.com/posts/1') } + + it 'initializes with given attributes' do + expect(subject.type).to eq('Like') + expect(subject.actor).to eq('https://example.com/users/alice') + expect(subject.object).to eq('https://example.com/posts/1') + end + end + + describe '#to_json' do + subject { described_class.new(type: 'Like', actor: 'https://example.com/users/alice', object: 'https://example.com/posts/1') } + + it 'converts the activity to a JSON representation' do + json_output = subject.to_json + parsed_output = JSON.parse(json_output) + + expect(parsed_output['type']).to eq('Like') + expect(parsed_output['actor']).to eq('https://example.com/users/alice') + expect(parsed_output['object']).to eq('https://example.com/posts/1') + end + end + + describe '.from_h' do + let(:activity_hash) { { 'type' => 'Like', 'actor' => 'https://example.com/users/alice', 'object' => 'https://example.com/posts/1' } } + + it 'loads an activity from a hash' do + activity = described_class.from_h(activity_hash) + + expect(activity.type).to eq('Like') + expect(activity.actor).to eq('https://example.com/users/alice') + expect(activity.object).to eq('https://example.com/posts/1') + end + end +end \ No newline at end of file diff --git a/spec/activitypub_spec.rb b/spec/activitypub_spec.rb new file mode 100644 index 0000000..bc4d35d --- /dev/null +++ b/spec/activitypub_spec.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +RSpec.describe Activitypub do + it "has a version number" do + expect(Activitypub::VERSION).not_to be nil + end +end diff --git a/spec/actor_spec.rb b/spec/actor_spec.rb new file mode 100644 index 0000000..6251488 --- /dev/null +++ b/spec/actor_spec.rb @@ -0,0 +1,70 @@ +RSpec.describe ActivityPub::Actor do + describe '#initialize' do + subject do + described_class.new( + id: 'https://example.com/users/alice', + name: 'Alice', + preferred_username: 'alice', + inbox: 'https://example.com/users/alice/inbox', + outbox: 'https://example.com/users/alice/outbox' + ) + end + + it 'initializes with given attributes' do + expect(subject.id).to eq('https://example.com/users/alice') + expect(subject.type).to eq('Person') + expect(subject.name).to eq('Alice') + expect(subject.preferred_username).to eq('alice') + expect(subject.inbox).to eq('https://example.com/users/alice/inbox') + expect(subject.outbox).to eq('https://example.com/users/alice/outbox') + end + end + + describe '#to_json' do + subject do + described_class.new( + id: 'https://example.com/users/alice', + name: 'Alice', + preferred_username: 'alice', + inbox: 'https://example.com/users/alice/inbox', + outbox: 'https://example.com/users/alice/outbox' + ) + end + + it 'converts the actor to a JSON representation' do + json_output = subject.to_json + parsed_output = JSON.parse(json_output) + + expect(parsed_output['id']).to eq('https://example.com/users/alice') + expect(parsed_output['type']).to eq('Person') + expect(parsed_output['name']).to eq('Alice') + expect(parsed_output['preferredUsername']).to eq('alice') + expect(parsed_output['inbox']).to eq('https://example.com/users/alice/inbox') + expect(parsed_output['outbox']).to eq('https://example.com/users/alice/outbox') + end + end + + describe '.from_h' do + let(:actor_hash) do + { + 'id' => 'https://example.com/users/alice', + 'type' => 'Person', + 'name' => 'Alice', + 'preferredUsername' => 'alice', + 'inbox' => 'https://example.com/users/alice/inbox', + 'outbox' => 'https://example.com/users/alice/outbox' + } + end + + it 'loads an actor from a hash' do + actor = described_class.from_h(actor_hash) + + expect(actor.id).to eq('https://example.com/users/alice') + expect(actor.type).to eq('Person') + expect(actor.name).to eq('Alice') + expect(actor.preferred_username).to eq('alice') + expect(actor.inbox).to eq('https://example.com/users/alice/inbox') + expect(actor.outbox).to eq('https://example.com/users/alice/outbox') + end + end +end \ No newline at end of file diff --git a/spec/inbox_spec.rb b/spec/inbox_spec.rb new file mode 100644 index 0000000..6c64374 --- /dev/null +++ b/spec/inbox_spec.rb @@ -0,0 +1,36 @@ +# spec/activitypub/inbox_spec.rb + +RSpec.describe ActivityPub::Inbox do + let(:actor_id) { "https://example.com/actors/1" } + let(:keypair) { ActivityPub::Signature.generate_keypair } + let(:activity_data) { '{"type":"Note","content":"Hello, world!"}' } + let(:headers) { { "Signature" => ActivityPub::Signature.sign(activity_data, keypair[:private]) } } + let(:public_key_response) do + { + publicKey: { + id: "#{actor_id}#main-key", + owner: actor_id, + publicKeyPem: keypair[:public] + } + }.to_json + end + + subject { described_class.new(actor_id: actor_id) } + + # Stubbing the HTTP request to return our example public key + before do + allow(Net::HTTP).to receive(:get).with(URI(actor_id)).and_return(public_key_response) + end + + describe '#accept_activity' do + it 'accepts and processes a valid activity' do + expect { subject.accept_activity(activity_data, headers) }.not_to raise_error + end + + it 'raises an error for an activity with an invalid signature' do + tampered_headers = headers.merge("Signature" => "INVALID_SIGNATURE") + + expect { subject.accept_activity(activity_data, tampered_headers) }.to raise_error("Invalid signature") + end + end +end diff --git a/spec/object_spec.rb b/spec/object_spec.rb new file mode 100644 index 0000000..606dd17 --- /dev/null +++ b/spec/object_spec.rb @@ -0,0 +1,47 @@ +# spec/activitypub/object_spec.rb + + +RSpec.describe ActivityPub::Object do + let(:current_time) { Time.now.utc.iso8601 } + + describe '#initialize' do + subject { described_class.new(id: 'https://example.com/objects/1', content: 'Hello world!') } + + it 'initializes with given attributes' do + expect(subject.id).to eq('https://example.com/objects/1') + expect(subject.type).to eq('Object') + expect(subject.content).to eq('Hello world!') + expect(subject.published).to be_a(String) # Checking if it's a formatted string for simplicity. In a real-world scenario, you might want to parse and validate. + expect(subject.updated).to be_a(String) + end + end + + describe '#to_json' do + subject { described_class.new(id: 'https://example.com/objects/1', content: 'Hello world!', published: current_time, updated: current_time) } + + it 'converts the object to a JSON representation' do + json_output = subject.to_json + parsed_output = JSON.parse(json_output) + + expect(parsed_output['id']).to eq('https://example.com/objects/1') + expect(parsed_output['type']).to eq('Object') + expect(parsed_output['content']).to eq('Hello world!') + expect(parsed_output['published']).to eq(current_time) + expect(parsed_output['updated']).to eq(current_time) + end + end + + describe '.from_h' do + let(:object_hash) { { 'id' => 'https://example.com/objects/1', 'type' => 'Object', 'content' => 'Hello world!', 'published' => current_time, 'updated' => current_time } } + + it 'loads an object from a hash' do + obj = described_class.from_h(object_hash) + + expect(obj.id).to eq('https://example.com/objects/1') + expect(obj.type).to eq('Object') + expect(obj.content).to eq('Hello world!') + expect(obj.published).to eq(current_time) + expect(obj.updated).to eq(current_time) + end + end +end diff --git a/spec/outbox_spec.rb b/spec/outbox_spec.rb new file mode 100644 index 0000000..9270420 --- /dev/null +++ b/spec/outbox_spec.rb @@ -0,0 +1,33 @@ +# spec/activitypub/outbox_spec.rb + +RSpec.describe ActivityPub::Outbox do + let(:actor_id) { "https://example.com/actors/1" } + let(:inbox_url) { "https://recipient.com/actors/2/inbox" } + let(:keypair) { ActivityPub::Signature.generate_keypair } + let(:activity_data) { '{"type":"Note","content":"Hello from outbox!"}' } + + subject { described_class.new(actor_id: actor_id, private_key: keypair[:private]) } + + # Stubbing the HTTP request to avoid actual posts during tests + before do + stub_request(:post, "http://recipient.com:443/actors/2/inbox") + .with( + body: activity_data, + headers: { + 'Accept'=>'*/*', + 'Accept-Encoding'=>'gzip;q=1.0,deflate;q=0.6,identity;q=0.3', + 'Content-Type' => 'application/ld+json', + 'User-Agent'=>'Ruby', + 'Signature' => ActivityPub::Signature.sign(activity_data, keypair[:private]) + } + ) + .to_return(status: 200, body: "", headers: {}) + end + + describe '#send_activity' do + it 'sends the activity to the target inbox' do + response = subject.send_activity(inbox_url, activity_data) + expect(response.code).to eq("200") + end + end +end diff --git a/spec/signature_spec.rb b/spec/signature_spec.rb new file mode 100644 index 0000000..de01d1a --- /dev/null +++ b/spec/signature_spec.rb @@ -0,0 +1,36 @@ +# spec/activitypub/signature_spec.rb + + +RSpec.describe ActivityPub::Signature do + let(:keypair) { described_class.generate_keypair } + let(:data) { "Important data that needs to be signed" } + + describe '.generate_keypair' do + it 'generates a valid RSA key pair' do + expect(keypair[:private]).to be_a(String) + expect(keypair[:public]).to be_a(String) + end + end + + describe '.sign' do + let(:signature) { described_class.sign(data, keypair[:private]) } + + it 'signs data using a private key' do + expect(signature).to be_a(String) + # Note: In a real-world scenario, you'd have more comprehensive checks + # or perhaps try verifying the signature as a test. + end + end + + describe '.verify?' do + let(:signature) { described_class.sign(data, keypair[:private]) } + + it 'verifies the signed data with the correct public key' do + expect(described_class.verify?(data, signature, keypair[:public])).to be_truthy + end + + it 'returns false for incorrect data' do + expect(described_class.verify?("Tampered data", signature, keypair[:public])).to be_falsey + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb new file mode 100644 index 0000000..f1aded9 --- /dev/null +++ b/spec/spec_helper.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require "activitypub" +require "webmock/rspec" +require "pry" + +RSpec.configure do |config| + # Enable flags like --only-failures and --next-failure + config.example_status_persistence_file_path = ".rspec_status" + + # Disable RSpec exposing methods globally on `Module` and `main` + config.disable_monkey_patching! + + config.expect_with :rspec do |c| + c.syntax = :expect + end +end