# TODO # run as service # federation # client post media # client follow # client get media # server follow # test with pleroma etc require 'json' require 'net/http' require 'uri' require 'base64' require 'securerandom' require 'fileutils' require 'digest/sha2' require 'nokogiri' 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) 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 "Create" File.open(File.join("inbox", SecureRandom.uuid + ".json"), "w+") { |f| f.puts input } 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"] # puts o case o["type"] when "Follow" Dir["followers/*.json"].each do |follower| puts follower # puts JSON.parse(File.read(follower))["actor"] if JSON.parse(File.read(follower))["actor"] == o["actor"] FileUtils.rm follower end end # ordered_collection("followers") end # puts input 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 "/outbox" # receive from client if auth(env) 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 "/.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 "/inbox" if auth(env) type, response = format ordered_collection(env["REQUEST_PATH"]), env["HTTP_ACCEPT"] 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 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" => "" } 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, } 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("
") 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 format response, accept if accept == "text/plain" response = response["orderedItems"].collect.with_index do |r, i| object = r["object"] doc = Nokogiri::HTML(object["content"]) str = "#{i}\t#{object["published"]}\t#{}\n#{doc.text}" str << "\n" + object["attachment"].collect { |att| `kitty +kitten icat #{att["url"]}` }.join("\n") if object["attachment"] str end.join("\n\n") type = "text/plain" else response = response.to_json type = 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"' end [type, 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 # puts 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 ordered_collection dir collection = dir.sub(/^\//, "") posts = Dir[File.join(collection, "*.json")].collect { |f| p f; JSON.parse(File.read f) }.sort_by { |o| o["published"] } { "@context" => "https://www.w3.org/ns/activitystreams", "summary" => "#{USER} #{collection}", "type" => "OrderedCollection", "totalItems" => posts.size, "orderedItems" => posts, } end def inbox uri URI(get(uri)["inbox"]).request_uri 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 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) 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] end end