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