# TODO # delete all/thread # anchors (return after delete) # boost # archive # federation # client post media # test with pleroma etc require 'uri' require 'base64' require 'digest/sha2' 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) enable :sessions set :session_secret, File.read(".secret").chomp set :default_content_type, 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"' set :port, 9292 before "/" do if request.request_method == "POST" halt 400 unless verify_signature(request.env) end end post "/inbox" do # server-server request.body.rewind # in case someone already read it body = request.body.read action = JSON.parse body case action["type"] when "Create" create action["object"] when "Delete" delete action["object"] when "Update" delete action["object"] create action["object"] when "Follow" File.open(File.join("public", "followers", mention(action["actor"]) + ".json"), "w+") { |f| f.puts body } accept = { "@context" => "https://www.w3.org/ns/activitystreams", "id" => File.join(SOCIAL_URL + "#accepts", SecureRandom.uuid), "type" => "Accept", "actor" => ACTOR, "object" => action } send_signed accept, action["actor"] when "Undo" o = action["object"] case o["type"] when "Follow" Dir["public/followers/*.json"].each do |follower| FileUtils.rm follower if JSON.parse(File.read(follower))["actor"] == o["actor"] end end #else #p body end end post "/outbox" do # client-server protected! request.body.rewind # in case someone already read it body = request.body.read date = Time.now.strftime("%Y-%m-%dT%H:%M:%S") # TODO hashtags, replys outbox_path = File.join("public/outbox", date + ".json") object_path = File.join("public/objects", date + ".json") create = { "@context" => "https://www.w3.org/ns/activitystreams", "id" => File.join(SOCIAL_URL, outbox_path), "type" => "Create", "actor" => ACTOR, "object" => { "id" => File.join(SOCIAL_URL, object_path), "type" => "Note", "attributedTo" => ACTOR, "published" => date, "content" => "", "to" => ["https://www.w3.org/ns/activitystreams#Public"] }, "published" => date, "to" => ["https://www.w3.org/ns/activitystreams#Public"] } recipients = [] if /^@/.match body mentions, body = body.split("\n", 2) mentions.split(/, */).each do |m| recipients << actor(m.chomp) end end create["object"]["content"] = body.lines.select { |l| !l.empty? }.join("
") recipients += Dir[File.join("public/followers", "*.json")].collect { |f| JSON.parse(File.read(f))["actor"] } recipients.delete ACTOR recipients.uniq! create["object"]["to"] += recipients create["to"] += recipients File.open(outbox_path, "w+") { |f| f.puts create.to_json } File.open(object_path, "w+") { |f| f.puts create["object"].to_json } recipients.each { |r| send_signed create, r } end post "/delete/*" do protected! FileUtils.rm params['splat'][0] redirect to("/") end post "/follow/*" do protected! mention = params['splat'][0] actor = actor(mention) following_path = File.join("public", "following", mention + ".json") follow = { "@context" => "https://www.w3.org/ns/activitystreams", "id" => File.join(SOCIAL_URL, following_path), "type" => "Follow", "actor" => ACTOR, "object" => actor } send_signed follow, actor File.open(following_path, "w+") { |f| f.puts follow.to_json } redirect to("/") end post "/unfollow/*" do protected! mention = params['splat'][0] actor = actor(mention) following_path = File.join("public", "following", mention + ".json") if File.exists?(following_path) undo = { "@context" => "https://www.w3.org/ns/activitystreams", "id" => File.join(SOCIAL_URL + "#undo", SecureRandom.uuid), "type" => "Undo", "actor" => ACTOR, "object" => JSON.parse(File.read(following_path)) } send_signed undo, actor FileUtils.rm following_path redirect to("/") end end post "/login" do session["client"] = true if params["secret"] == File.read(".pwd").chomp redirect to("/") end get "/.well-known/webfinger" do if request["resource"] == "acct:#{ACCOUNT}" send_file "./public/webfinger", :type => "application/jrd+json" else halt 404 end end get "/", :provides => 'html' do protected! items = Dir['./inbox/*.json'].sort.collect do |file| item = JSON.parse(File.read(file)) mention = mention(item['attributedTo']) following_path = File.join('public', 'following', mention + '.json') File.exists?(following_path) ? follow = 'unfollow' : follow = 'follow' { :id => item['id'], :parent => item['inReplyTo'], :file => file, :actor_url => item['attributedTo'], :mention => mention, :follow => follow, :content => item['content'], :attachment => item['attachment'], :replies => [] } end.compact @inbox = [] items.each do |i| if i[:parent].nil? or items.select{|it| it[:id] == i[:parent] }.empty? @inbox << i else #p i items.select{|it| it[:id] == i[:parent] }.each{|it| it[:replies] << i} end end html=" " @inbox.each do |item| html += html(item) end html+=' ' #erb :index html end ["/outbox","/following","/followers"].each do |path| get path do ordered_collection(path).to_json end end helpers do def protected! redirect("/login.html") unless session['client'] end end def html item, indent=2 html = "
#{ item[:mention] } 
 
#{ item[:content].gsub('
','') }" if item[:attachment] item[:attachment].each do |att| html << "
" case att['mediaType'] when /audio/ html << "" when /image/ html << "" when /video/ html << "" else html << "#{ att }
#{ att['url'] }" end end end html << "
" item[:replies].each do |r| html << html(r,indent+4) end html end def delete object Dir["inbox/*.json"].each do |doc| FileUtils.rm doc if JSON.parse(File.read(doc))["id"] == object["id"] end end def create object unless object['type'] == 'Person' doc = File.join("inbox", "#{Time.now.strftime('%Y-%m-%dT%H:%M:%S.%N')}.json") File.open(doc, "w+") { |f| f.puts object.to_json } end end def mention actor people = File.read('inbox/people.tsv').split("\n").collect {|l| l.chomp.split("\t")} person = people.select{|p| p[1] == actor} #p person if person.empty? #p actor mention = "#{fetch(actor)["preferredUsername"]}@#{URI(actor).host}" File.open('inbox/people.tsv','a'){|f| f.puts "#{mention}\t#{actor}"} mention else person[0][0] end end def actor mention mention = mention.sub(/^@/, '').chomp user, server = mention.split("@") fetch("https://#{server}/.well-known/webfinger?resource=acct:#{mention}", "application/jrd+json")["links"].select { |l| l["rel"] == "self" }[0]["href"] end def fetch url, accept = 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"', limit = 10 p url 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) case response when Net::HTTPSuccess return JSON.parse(response.body) when Net::HTTPRedirection fetch response['location'], accept, limit-1 else puts "Unknown response: #{response.code}, #{response.message}\n#{response.code}" end end def ordered_collection dir posts = Dir[File.join("public",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 def send_signed object, url # https://github.com/mastodon/mastodon/blob/main/app/lib/request.rb keypair = OpenSSL::PKey::RSA.new(File.read('private.pem')) 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 + '"' =begin uri = URI.parse(fetch(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 p uri, body http.request(request) puts "/run/current-system/sw/bin/curl -X POST -H 'Content-Type: application/activity+json' -H 'Host: #{uri.host}' -H 'Date: #{date}' -H 'Digest: #{digest}' -H 'Signature: #{signed_header}' -d '#{body}' #{url}" =end `/run/current-system/sw/bin/curl -X POST -H 'Content-Type: application/activity+json' -H 'Host: #{uri.host}' -H 'Date: #{date}' -H 'Digest: #{digest}' -H 'Signature: #{signed_header}' -d '#{body}' #{url}` end def inbox uri URI(fetch(uri)["inbox"]).request_uri end def verify_signature env # https://github.com/mastodon/mastodon/blob/main/app/controllers/concerns/signature_verification.rb # TODO verify digest #begin 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 = fetch 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) #rescue #false #end end