summaryrefslogtreecommitdiff
path: root/activitypub.rb
diff options
context:
space:
mode:
Diffstat (limited to 'activitypub.rb')
-rw-r--r--activitypub.rb445
1 files changed, 445 insertions, 0 deletions
diff --git a/activitypub.rb b/activitypub.rb
new file mode 100644
index 0000000..400465a
--- /dev/null
+++ b/activitypub.rb
@@ -0,0 +1,445 @@
+# TODO
+# boost
+# archive
+# threads
+# federation
+# client post media
+# test with pleroma etc
+=begin
+require 'json'
+require 'uri'
+require 'base64'
+require 'securerandom'
+require 'fileutils'
+require 'digest/sha2'
+require 'nokogiri'
+=end
+require 'net/http'
+require 'sinatra'
+
+USER = "pdp8"
+WWW_DOMAIN = "pdp8.info"
+WWW_URL = "https://#{WWW_DOMAIN}"
+SOCIAL_DOMAIN = "social.#{WWW_DOMAIN}"
+
+ACCOUNT = "#{USER}@#{SOCIAL_DOMAIN}"
+SOCIAL_URL = "https://#{SOCIAL_DOMAIN}"
+ACTOR = File.join(SOCIAL_URL, USER)
+
+use Rack::Reloader
+set :default_content_type, 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'
+set :port, 9292
+
+post "/inbox" do
+ request.body.rewind # in case someone already read it
+ body = request.body.read
+ object = JSON.parse body
+ case object["type"]
+ when "Create"
+ File.open(File.join("inbox", "#{object["published"]}-#{mention object["actor"]}.json"), "w+") { |f|
+ f.puts object["object"].to_json
+ }
+ end
+end
+
+get "/.well-known/webfinger" do
+ if request["resource"] == "acct:#{ACCOUNT}"
+ send_file "./webfinger", :type => "application/jrd+json"
+ else
+ 404
+ end
+end
+
+get "/inbox", :provides => 'html' do
+ template = "<!DOCTYPE html>
+ <html lang='en'>
+ <body>
+ <% Dir['./inbox/*'].sort.each do |file| %>
+ <% item = JSON.parse(File.read(file)) %>
+ <b><%= mention item['actor'] %></b>&nbsp;<i><%= item['published'].sub('T', ' ') %></i>
+ <p><%= item['content'] %>
+ <% if item['attachment']
+ item['attachment'].each do |att|
+ case att['mediaType']
+ when /audio/ %>
+ <br><audio controls=''><source src='<%= att['url'] %>' type='<%= att['mediaType'] %>'></audio>
+ <% when /image/ %>
+ <br><a href='<%= att['url'] %>'><img src='<%= att['url'] %>'></a>
+ <% when /video/ %>
+ <br><video controls=''><source src='<%= att['url'] %>' type='<%= att['mediaType'] %>'></video>
+ <% else %>
+ <%= att %><br>
+ <a href='<%= att['url'] %>'><%= att['url'] %></a>
+ <% end %>
+ <% end %>
+ <% end %>
+ <p>
+ <form action='<%= File.join 'delete', file %>' method='post'>
+ <button>Delete</button>
+ </form>
+ <form action='<%= File.join 'boost', file %>' method='post'>
+ <button>Boost</button>
+ </form>
+ <form action='<%= File.join 'archive', file %>' method='post'>
+ <button>Archive</button>
+ </form>
+ <form action='<%= File.join 'reply', file %>' method='post'>
+ <button>Reply</button>
+ </form>
+ <hr>
+ <% end %>
+ </body>
+ </html>"
+ erb template
+end
+
+get "/inbox", :provides => 'json' do
+ ordered_collection("inbox").to_json
+end
+
+def mention actor
+ "#{get(actor)["preferredUsername"]}@#{URI(actor).host}"
+end
+
+def get url, accept = 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'
+ uri = URI(url)
+ http = Net::HTTP.new(uri.host, uri.port)
+ http.use_ssl = true
+ header = { 'Accept' => accept }
+ request = Net::HTTP::Get.new(uri.request_uri, header)
+ response = http.request(request)
+ JSON.parse(response.body)
+end
+
+def ordered_collection dir
+ posts = Dir[File.join(dir, "*.json")].collect { |f| JSON.parse(File.read f) }.sort_by { |o| o["published"] }
+ {
+ "@context" => "https://www.w3.org/ns/activitystreams",
+ "summary" => "#{USER} #{dir}",
+ "type" => "OrderedCollection",
+ "totalItems" => posts.size,
+ "orderedItems" => posts,
+ }
+end
+=begin
+post "/outbox" do
+
+end
+
+class Application
+ def call(env)
+ code = 404
+ type = 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'
+ response = "Not found."
+
+ case env['REQUEST_METHOD']
+
+ when 'POST'
+ input = env["rack.input"].read
+ case env["REQUEST_PATH"]
+
+ when "/inbox" # receive from server
+ if verify(env)
+ begin
+ object = JSON.parse(input)
+ case object["type"]
+ when "Delete"
+ puts input
+ when "Follow"
+ File.open(File.join("followers", SecureRandom.uuid + ".json"), "w+") { |f| f.puts input }
+ accept = { "@context" => "https://www.w3.org/ns/activitystreams",
+ "id" => File.join(SOCIAL_URL + "#accepts", SecureRandom.uuid),
+ "type" => "Accept",
+ "actor" => ACTOR,
+ "object" => JSON.parse(input) }
+ send accept, [accept["object"]["actor"]]
+ when "Undo"
+ o = object["object"]
+ case o["type"]
+ when "Follow"
+ Dir["followers/*.json"].each do |follower|
+ if JSON.parse(File.read(follower))["actor"] == o["actor"]
+ FileUtils.rm follower
+ end
+ end
+ else
+ puts input
+ end
+ else
+ puts input
+ end
+ code = 200
+ response = "OK"
+ rescue => e
+ puts input, e.to_s
+ response = "Request body contains invalid json."
+ end
+ else
+ code = 403
+ response = "Key verification failed for POST to #{env["REQUEST_URI"]}."
+ end
+
+ when %r{/delete} # receive from client
+ if auth(env)
+ FileUtils.rm env["REQUEST_URI"].sub("/delete/", "")
+ return [302, { "Location" => "/inbox" }, []]
+ end
+
+ when "/outbox" # receive from client
+ p "outbox"
+ if auth(env)
+ p "OK"
+ code, response = parse input
+ else
+ code = 403
+ response = "You are not allowed to POST to #{env["REQUEST_URI"]}."
+ end
+
+ when "/follow" # receive from client
+ if auth(env)
+ input.split.each do |mention|
+ actor = actor(mention)
+ follow = { "@context" => "https://www.w3.org/ns/activitystreams",
+ "id" => File.join(SOCIAL_URL, "following", SecureRandom.uuid + ".json"),
+ "type" => "Follow",
+ "actor" => ACTOR,
+ "object" => actor }
+ save follow
+ puts(send follow, [actor])
+ code = 200
+ response = "OK"
+ end
+ else
+ code = 403
+ response = "You are not allowed to POST to #{env["REQUEST_URI"]}."
+ end
+
+ when "/unfollow" # receive from client
+ if auth(env)
+ input.split.each do |mention|
+ actor = actor(mention)
+ Dir["following/*.json"].each do |f|
+ follow = JSON.parse(File.read(f))
+ puts follow
+ if follow["object"] == actor
+ undo = { "@context" => "https://www.w3.org/ns/activitystreams",
+ "id" => File.join(SOCIAL_URL + "#undo", SecureRandom.uuid),
+ "type" => "Undo",
+ "actor" => ACTOR,
+ "object" => follow }
+ send undo, [actor]
+ FileUtils.rm f
+ end
+ end
+ end
+ end
+ end
+
+ when 'GET'
+
+ case env["REQUEST_URI"] # REQUEST_PATH does not contain queries
+
+ when "/inbox"
+ if auth(env)
+ case env["HTTP_ACCEPT"]
+ else
+ type = "text/html"
+ response = html env["REQUEST_PATH"]
+ end
+ code = 200
+ else
+ code = 403
+ response = "You are not allowed to GET #{env["REQUEST_URI"]}."
+ end
+
+ when %r{/[outbox|following|followers|likes|shares]}
+ response = ordered_collection(env["REQUEST_PATH"]).to_json
+ code = 200
+ end
+
+ end
+ [code, { "Content-Type" => type }, [response]]
+ end
+
+ def html path
+
+ end
+=end
+
+=begin
+ def html o
+ html = "<!DOCTYPE html>
+<html lang='en'>
+ <body>
+ <b>#{mention o["actor"]}</b>&nbsp;<i>#{o["object"]["published"]}</i>
+ <p>#{o["object"]["content"]}
+ "
+ if o["object"]["attachment"]
+ o["object"]["attachment"].each do |att|
+ case att["mediaType"]
+ when /audio/
+ html<< "\n<br><audio controls=''><source src='#{att["url"]}' type='#{att["mediaType"]}'></audio>"
+ when /image/
+ html << "\n<br><a href='#{att["url"]}'><img src='#{att["url"]}'></a>"
+ when /video/
+ html<< "\n<br><video controls=''><source src='#{att["url"]}' type='#{att["mediaType"]}'></video>"
+ else
+ html<< att + "<br>"
+ html << "\n<a href='#{att["url"]}'>#{att["url"]}</a>"
+ end
+ end
+ end
+ end
+ html << "\n\t</body>\n</html>"
+ html
+ end
+=end
+
+=begin
+ def parse input
+ date = Time.now.strftime("%Y-%m-%dT%H:%M:%S")
+ # TODO media attachments, hashtags
+ note = {
+ "@context" => "https://www.w3.org/ns/activitystreams",
+ "id" => File.join(SOCIAL_URL, "note", SecureRandom.uuid + ".json"),
+ "type" => "Note",
+ "attributedTo" => ACTOR,
+ "published" => date,
+ "content" => "",
+ "to" => ["https://www.w3.org/ns/activitystreams#Public"]
+ }
+ create = {
+ "@context" => "https://www.w3.org/ns/activitystreams",
+ "id" => File.join(SOCIAL_URL, "create", SecureRandom.uuid + ".json"),
+ "type" => "Create",
+ "actor" => ACTOR,
+ "object" => note,
+ "published" => date,
+ "to" => ["https://www.w3.org/ns/activitystreams#Public"]
+ }
+ recipients = []
+ if /^@/.match input
+ mentions, input = input.split("\n", 2)
+ mentions.split(/, */).each do |m|
+ recipients << actor(m.chomp)
+ end
+ end
+ note["content"] = input.lines.select { |l| !l.empty? }.join("<br>")
+ recipients += Dir[File.join("followers", "*.json")].collect { |f| JSON.parse(File.read(f))["actor"] }
+ recipients.delete ACTOR
+ recipients.uniq!
+ note["to"] += recipients
+ create["to"] += recipients
+
+ save create
+ save note
+ FileUtils.ln_s File.join('..', path(create)), "outbox"
+
+ responses = send create, recipients
+ if responses.collect { |r| r.code.to_i }.uniq.max < 400
+ code = 200
+ response = "OK"
+ else
+ code = 502
+ response = responses.select { |r| r.code.to_i >= 400 }.collect { |r| r.body }.uniq
+ end
+ [code, response]
+ end
+
+ def actor mention
+ mention = mention.sub(/^@/, '').chomp
+ user, server = mention.split("@")
+ get("https://#{server}/.well-known/webfinger?resource=acct:#{mention}",
+ "application/jrd+json")["links"].select { |l|
+ l["rel"] == "self"
+ }[0]["href"]
+ end
+
+ def send object, urls
+ # https://github.com/mastodon/mastodon/blob/main/app/lib/request.rb
+ keypair = OpenSSL::PKey::RSA.new(File.read('private.pem'))
+ responses = []
+ urls.each do |url|
+ date = Time.now.utc.httpdate
+ uri = URI.parse(url)
+
+ sha256 = OpenSSL::Digest::SHA256.new
+ body = object.to_json
+ digest = "SHA-256=" + sha256.base64digest(body)
+
+ signed_string = "(request-target): post #{inbox uri}\nhost: #{uri.host}\ndate: #{date}\ndigest: #{digest}\ncontent-type: application/activity+json"
+ signature = Base64.strict_encode64(keypair.sign(OpenSSL::Digest.new('SHA256'), signed_string))
+ signed_header = 'keyId="' + ACTOR + '#main-key",algorithm="rsa-sha256",headers="(request-target) host date digest content-type",signature="' + signature + '"'
+
+ uri = URI.parse(get(url)["inbox"])
+ http = Net::HTTP.new(uri.host, uri.port)
+ http.use_ssl = true
+ header = {
+ 'Content-Type' => 'application/activity+json',
+ 'Host' => uri.host,
+ 'Date' => date,
+ 'Digest' => digest,
+ 'Signature' => signed_header,
+ }
+ request = Net::HTTP::Post.new(uri.request_uri, header)
+ request.body = body
+
+ responses << http.request(request)
+ end
+ # puts responses
+ responses
+ end
+
+ def inbox uri
+ URI(get(uri)["inbox"]).request_uri
+ end
+
+ def path object
+ object["id"].sub(SOCIAL_URL, '').sub('/', '')
+ end
+
+ def save object
+ path = path object
+ FileUtils.mkdir_p File.dirname(path)
+ File.open(path, "w+") { |f| f.puts object.to_json }
+ end
+
+ def verify env
+ # https://github.com/mastodon/mastodon/blob/main/app/controllers/concerns/signature_verification.rb
+ # TODO verify digest
+ signature_params = {}
+ env["HTTP_SIGNATURE"].split(',').each do |pair|
+ k, v = pair.split('=')
+ signature_params[k] = v.gsub('"', '')
+ end
+
+ key_id = signature_params['keyId']
+ headers = signature_params['headers']
+ signature = Base64.decode64(signature_params['signature'])
+
+ actor = get key_id
+ key = OpenSSL::PKey::RSA.new(actor['publicKey']['publicKeyPem'])
+
+ comparison = headers.split(' ').map do |signed_params_name|
+ if signed_params_name == '(request-target)'
+ '(request-target): post /inbox'
+ elsif signed_params_name == 'content-type'
+ "#{signed_params_name}: #{env["CONTENT_TYPE"]}"
+ else
+ "#{signed_params_name}: #{env["HTTP_" + signed_params_name.upcase]}"
+ end
+ end.join("\n")
+
+ key.verify(OpenSSL::Digest.new('SHA256'), signature, comparison)
+ # true
+ end
+
+ def auth env
+ auth = Rack::Auth::Basic::Request.new(env)
+ usr = File.read(".usr").chomp
+ pwd = File.read(".pwd").chomp
+ auth.provided? && auth.basic? && auth.credentials && auth.credentials == [usr, pwd]
+ true
+ end
+end
+=end