diff options
Diffstat (limited to 'activitypub.rb')
-rw-r--r-- | activitypub.rb | 445 |
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> <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> <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 |