require 'json' require 'net/http' require 'uri' require 'base64' require 'securerandom' require 'fileutils' require 'digest/sha2' 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) MATRIX = "@#{USER}:matrix.#{WWW_DOMAIN}" class Application def call(env) code = 404 type = 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"' response = "not allowed" # p env["rack.input"].read # if env["CONTENT_TYPE"] =~ /json/ # puts env["REMOTE_ADDR"] case env['REQUEST_METHOD'] when 'POST' input = env["rack.input"].read case env["REQUEST_URI"] when "/inbox" # receive from server puts "POST INBOX" if verify(env) begin # unless input.match(//) object = JSON.parse(input) # puts object case object["type"] when "Create" File.open(File.join("inbox", SecureRandom.uuid + ".json"), "w+") { |f| f.puts input } else puts input end code = 200 response = "OK" # end rescue => e # puts e.to_s puts "Verification ERROR: " # puts input response = "invalid json" end else code = 401 response = "not verified" end when "/outbox" # receive from client puts "POST OUTBOX" # TODO auth if auth(env) input = JSON.parse(input) input["type"] == "Create" ? activity = input : activity = activity(input) # expand object to create activity add_id activity save activity # , "outbox" FileUtils.ln_s File.join('..', path(activity)), "outbox" code, response = deliver activity, ["to", "bto", "cc", "bcc", "audience"].collect { |d| activity[d] }.flatten.uniq.compact code = 200 response = "OK" else code = 403 response = "forbidden" end end when 'GET' case env["REQUEST_URI"] when "/.well-known/webfinger?resource=acct:#{ACCOUNT}" type = "application/jrd+json" response = File.read("webfinger") code = 200 when "/#{USER}" # TODO serve html response = File.read(USER) code = 200 when %r{/[inbox|outbox|following|followers|likes|shares]} response = ordered_collection env["REQUEST_URI"] code = 200 end end # else # response = "Cannot serve Content-type: " + env["CONTENT_TYPE"] # end [code, { "Content-Type" => type }, [response]] end def add_id object object["id"] = File.join(SOCIAL_URL, object["type"].downcase, SecureRandom.uuid + ".json") end def activity object date = Time.now.strftime("%Y-%m-%dT%H:%M:%S") object["attributedTo"] = ACTOR object["published"] = date add_id object save object { "@context" => "https://www.w3.org/ns/activitystreams", "type" => "Create", "actor" => ACTOR, "object" => object, "published" => date, "to" => object["to"], "cc" => object["cc"] } 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 inbox uri http = Net::HTTP.new(uri.host, uri.port) http.use_ssl = true header = { 'Accept' => 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"' } request = Net::HTTP::Get.new(uri.request_uri, header) response = http.request(request) JSON.parse(response.body)["inbox"] end def deliver object, urls # https://github.com/mastodon/mastodon/blob/main/app/lib/request.rb keypair = OpenSSL::PKey::RSA.new(File.read('private.pem')) 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\nhost: #{uri.host}\ndate: #{date}\ndigest: #{digest}\ncontent-type: application/activity+json" puts signed_string 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 = { # 'Accept' => 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"', 'Content-Type' => 'application/activity+json', 'Host' => uri.host, 'Date' => date, 'Digest' => digest, 'Signature' => signed_header, } puts signed_header request = Net::HTTP::Post.new(uri.request_uri, header) request.body = body response = http.request(request) puts(response.body, response.code) # puts(response.code) # puts(response.body["signed_string"]) # puts(response.body["signature"]) end # [response.code, response.body] end def ordered_collection dir collection = dir.sub(/^\//, "") posts = Dir[File.join(collection, "*.json")].sort.reverse.collect { |f| p f; JSON.parse(File.read f) } { "@context" => "https://www.w3.org/ns/activitystreams", "summary" => "#{USER} #{collection}", "type" => "OrderedCollection", "totalItems" => posts.size, "orderedItems" => posts, }.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") puts comparison puts env["HTTP_SIGNATURE"] key.verify(OpenSSL::Digest.new('SHA256'), signature, comparison) end def get url uri = URI(url) http = Net::HTTP.new(uri.host, uri.port) http.use_ssl = true header = { 'Accept' => 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"' } request = Net::HTTP::Get.new(uri.request_uri, header) response = http.request(request) JSON.parse(response.body) end def auth env true end end