diff options
author | pdp8 <pdp8@pdp8.info> | 2023-06-21 12:51:05 +0200 |
---|---|---|
committer | pdp8 <pdp8@pdp8.info> | 2023-06-21 12:51:05 +0200 |
commit | e1a5a8283ff69eb906d4c93b31d8cec1fae1b6c5 (patch) | |
tree | 57fe14ad7ae3e26c17c64133158917163d42db22 | |
parent | b837b19b1950c7bc14a38aa5ea917e91b6f081dd (diff) |
server, client, helpers separated
-rw-r--r-- | activitypub.rb | 359 | ||||
-rw-r--r-- | client.rb | 116 | ||||
-rw-r--r-- | helpers.rb | 177 | ||||
-rw-r--r-- | public/favicon.ico | bin | 0 -> 11566 bytes | |||
-rw-r--r-- | server.rb | 76 |
5 files changed, 372 insertions, 356 deletions
diff --git a/activitypub.rb b/activitypub.rb index e250fe1..fd8583e 100644 --- a/activitypub.rb +++ b/activitypub.rb @@ -16,6 +16,7 @@ require 'uri' require 'base64' require 'digest/sha2' require 'sinatra' +require_relative 'helpers.rb' USER = "pdp8" WWW_DOMAIN = "pdp8.info" @@ -31,359 +32,5 @@ set :session_secret, File.read(".secret").chomp set :default_content_type, 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"' set :port, 9292 -# server-server -post "/inbox" do - verify! - 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 - - when "Accept" - o = action["object"] - case o["type"] - when "Follow" - File.open(File.join("public","following",mention(o['object'])+".json"),"w+"){|f| f.puts o.to_json} - end - - #when "Announce" - #when "Move" - #when "Add" - #when "Remove" - #when "Like" - #when "Block" - - else - p "Unknown action: #{action['type']}" - p body - end -end - -# client-server -post "/outbox" do - 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("<br>") - 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 "/archive" do - protected! - FileUtils.mv params['file'], "archive/" - redirect to(params['redirect']) -end - -post "/delete" do - protected! - FileUtils.rm params['file'] - redirect to(params['redirect']) -end - -post "/delete_all" do - protected! - FileUtils.rm Dir["inbox/*.json"] - redirect to("/") -end - -post "/follow/*/*" do - protected! - mention = params['splat'][0] - actor = actor(mention) - follow = { "@context" => "https://www.w3.org/ns/activitystreams", - "id" => File.join(SOCIAL_URL, "following", mention + ".json"), - "type" => "Follow", - "actor" => ACTOR, - "object" => actor } - send_signed follow, actor - 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 - -# public -get "/.well-known/webfinger" do - if request["resource"] == "acct:#{ACCOUNT}" - send_file "./public/webfinger", :type => "application/jrd+json" - else - halt 404 - end -end - -get "/pdp8", :provides => 'html' do - redirect 'https://pdp8.info' -end - -get "/pdp8" do - send_file "pdp8.json", :type => 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"' -end - -["/outbox","/following","/followers"].each do |path| - get path do - ordered_collection(path).to_json - end -end - -# private -["/", "/archive"].each do |path| - get path, :provides => 'html' do - protected! - if path == '/' - @dir = 'inbox' - @alt_dir = '/archive' - @alt_name = 'archive' - else - @dir = path.sub('/','') - @alt_dir = '/' - @alt_name = 'inbox' - end - threads - erb :index - end -end - -helpers do - - def protected! - redirect("/login.html") unless session['client'] - end - - def verify! - # https://github.com/mastodon/mastodon/blob/main/app/controllers/concerns/signature_verification.rb - # TODO verify digest - begin - signature_params = {} - request.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}: #{request.env["CONTENT_TYPE"]}" - else - "#{signed_params_name}: #{request.env["HTTP_" + signed_params_name.upcase]}" - end - end.join("\n") - - halt 400 unless key.verify(OpenSSL::Digest.new('SHA256'), signature, comparison) - rescue => e - p request.env["HTTP_SIGNATURE"], e - halt 400 - end - end - - def threads - nr = 0 - items = Dir[File.join(@dir, '*.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' - nr += 1 - { :id => item['id'], - :nr => nr, - :parent => item['inReplyTo'], - :file => file, - :actor_url => item['attributedTo'], - :mention => mention, - :follow => follow, - :content => item['content'], - :attachment => item['attachment'], - :indent => 2, - :replies => [] - } - end.compact - items.last[:nr] = items.last[:nr] - 2 - @threads = [] - items.each do |i| - if i[:parent].nil? or items.select{|it| it[:id] == i[:parent] }.empty? - @threads << i - else - items.select{|it| it[:id] == i[:parent] }.each do |it| - i[:indent] = it[:indent] + 2 - it[:replies] << i - end - end - end - end - - def html item - @item = item - erb :item - 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 people - File.read('inbox/people.tsv').split("\n").collect {|l| l.chomp.split("\t")} - end - - def mention actor - person = people.select{|p| p[1] == actor} - if person.empty? - 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 - actors = people.select{|p| p[0] == mention} - if actors.empty? - user, server = mention.split("@") - actor = fetch("https://#{server}/.well-known/webfinger?resource=acct:#{mention}", - "application/jrd+json")["links"].select { |l| - l["rel"] == "self" - }[0]["href"] - File.open('inbox/people.tsv','a'){|f| f.puts "#{mention}\t#{actor}"} - actor - else - actors[0][0] - end - end - - def fetch url, accept = 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"' - p url - response = `/run/current-system/sw/bin/curl --fail-with-body -sSL -H 'Accept: #{accept}' #{url}` - halt 400 unless $?.success? - JSON.parse(response) - 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 #{fetch(uri)["inbox"]}\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 + '"' - - puts `/run/current-system/sw/bin/curl -i -X POST -H 'Content-Type: application/activity+json' -H 'Host: #{uri.host}' -H 'Date: #{date}' -H 'Digest: #{digest}' -H 'Signature: #{signed_header}' -d '#{body}' #{fetch(url)['inbox']}` - end - -end +require_relative 'server.rb' +require_relative 'client.rb' diff --git a/client.rb b/client.rb new file mode 100644 index 0000000..2067937 --- /dev/null +++ b/client.rb @@ -0,0 +1,116 @@ + +# client-server +post "/outbox" do + 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("<br>") + 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 "/archive" do + protected! + FileUtils.mv params['file'], "archive/" + redirect to(params['redirect']) +end + +post "/delete" do + protected! + FileUtils.rm params['file'] + redirect to(params['redirect']) +end + +post "/delete_all" do + protected! + FileUtils.rm Dir["inbox/*.json"] + redirect to("/") +end + +post "/follow/*/*" do + protected! + mention = params['splat'][0] + actor = actor(mention) + follow = { "@context" => "https://www.w3.org/ns/activitystreams", + "id" => File.join(SOCIAL_URL, "following", mention + ".json"), + "type" => "Follow", + "actor" => ACTOR, + "object" => actor } + send_signed follow, actor + 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 + +# private +["/", "/archive"].each do |path| + get path, :provides => 'html' do + protected! + if path == '/' + @dir = 'inbox' + @alt_dir = '/archive' + @alt_name = 'archive' + else + @dir = path.sub('/','') + @alt_dir = '/' + @alt_name = 'inbox' + end + threads + erb :index + end +end diff --git a/helpers.rb b/helpers.rb new file mode 100644 index 0000000..b8f65de --- /dev/null +++ b/helpers.rb @@ -0,0 +1,177 @@ + +helpers do + + def protected! + redirect("/login.html") unless session['client'] + end + + def verify! + # https://github.com/mastodon/mastodon/blob/main/app/controllers/concerns/signature_verification.rb + # TODO verify digest + begin + signature_params = {} + request.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}: #{request.env["CONTENT_TYPE"]}" + else + "#{signed_params_name}: #{request.env["HTTP_" + signed_params_name.upcase]}" + end + end.join("\n") + + halt 400 unless key.verify(OpenSSL::Digest.new('SHA256'), signature, comparison) + rescue => e + p request.env["HTTP_SIGNATURE"], e + halt 400 + end + end + + def items + nr = 0 + @items = Dir[File.join(@dir, '*.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' + nr += 1 + { :id => item['id'], + :nr => nr, + :parent => item['inReplyTo'], + :file => file, + :actor_url => item['attributedTo'], + :mention => mention, + :follow => follow, + :content => item['content'], + :attachment => item['attachment'], + :indent => 2, + :replies => [] + } + end.compact + @items.last[:nr] = @items.last[:nr] - 2 unless @items.empty? + end + + def threads + items + @threads = [] + @items.each do |i| + if i[:parent].nil? or @items.select{|it| it[:id] == i[:parent] }.empty? + @threads << i + else + @items.select{|it| it[:id] == i[:parent] }.each do |it| + i[:indent] = it[:indent] + 2 + it[:replies] << i + end + end + end + end + + def html item + @item = item + erb :item + 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 } + if object['inReplyTo'] + @dir = 'inbox' + items + if @items.select{|it| it[:id] == object['inReplyTo'] }.empty? + download object['inReplyTo'] + end + end + end + end + + def download object_url + create fetch(object_url) + end + + def people + File.read('cache/people.tsv').split("\n").collect {|l| l.chomp.split("\t")} + end + + def mention actor + person = people.select{|p| p[1] == actor} + if person.empty? + mention = "#{fetch(actor)["preferredUsername"]}@#{URI(actor).host}" + File.open('cache/people.tsv','a'){|f| f.puts "#{mention}\t#{actor}"} + mention + else + person[0][0] + end + end + + def actor mention + mention = mention.sub(/^@/, '').chomp + actors = people.select{|p| p[0] == mention} + if actors.empty? + user, server = mention.split("@") + actor = fetch("https://#{server}/.well-known/webfinger?resource=acct:#{mention}", + "application/jrd+json")["links"].select { |l| + l["rel"] == "self" + }[0]["href"] + File.open('cache/people.tsv','a'){|f| f.puts "#{mention}\t#{actor}"} + actor + else + actors[0][0] + end + end + + def fetch url, accept = 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"' + p url + response = `/run/current-system/sw/bin/curl --fail-with-body -sSL -H 'Accept: #{accept}' #{url}` + halt 400 unless $?.success? + JSON.parse(response) + 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 + host = URI.parse(url).host + inbox = fetch(url)["inbox"] + + sha256 = OpenSSL::Digest::SHA256.new + body = object.to_json + digest = "SHA-256=" + sha256.base64digest(body) + + signed_string = "(request-target): post #{inbox}\nhost: #{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 + '"' + + puts `/run/current-system/sw/bin/curl -i -X POST -H 'Content-Type: application/activity+json' -H 'Host: #{host}' -H 'Date: #{date}' -H 'Digest: #{digest}' -H 'Signature: #{signed_header}' -d '#{body}' #{inbox}` + end + +end diff --git a/public/favicon.ico b/public/favicon.ico Binary files differnew file mode 100644 index 0000000..23625f9 --- /dev/null +++ b/public/favicon.ico diff --git a/server.rb b/server.rb new file mode 100644 index 0000000..13153b8 --- /dev/null +++ b/server.rb @@ -0,0 +1,76 @@ +# server-server +post "/inbox" do + verify! + 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 + + when "Accept" + o = action["object"] + case o["type"] + when "Follow" + File.open(File.join("public","following",mention(o['object'])+".json"),"w+"){|f| f.puts o.to_json} + end + + when "Announce" + download action["object"] + #when "Move" + #when "Add" + #when "Remove" + #when "Like" + #when "Block" + + else + p "Unknown action: #{action['type']}" + p body + end +end + +# public +get "/.well-known/webfinger" do + request["resource"] == "acct:#{ACCOUNT}" ? send_file("./public/webfinger", :type => "application/jrd+json") : halt(404) +end + +get "/pdp8", :provides => 'html' do + redirect 'https://pdp8.info' +end + +get "/pdp8" do + send_file "pdp8.json", :type => 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"' +end + +["/outbox","/following","/followers"].each do |path| + get path do + ordered_collection(path).to_json + end +end |