From 451757d05a6464194a741c54e879b338d6329bd6 Mon Sep 17 00:00:00 2001 From: pdp8 Date: Mon, 29 May 2023 17:17:26 +0200 Subject: initial sinatra version --- activitypub.rb | 445 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ social | 49 ------- 2 files changed, 445 insertions(+), 49 deletions(-) create mode 100644 activitypub.rb delete mode 100755 social 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 = " + + + <% Dir['./inbox/*'].sort.each do |file| %> + <% item = JSON.parse(File.read(file)) %> + <%= mention item['actor'] %> <%= item['published'].sub('T', ' ') %> +

<%= item['content'] %> + <% if item['attachment'] + item['attachment'].each do |att| + case att['mediaType'] + when /audio/ %> +
+ <% when /image/ %> +
'>'> + <% when /video/ %> +
+ <% else %> + <%= att %>
+ '><%= att['url'] %> + <% end %> + <% end %> + <% end %> +

+

' method='post'> + +
+
' method='post'> + +
+
' method='post'> + +
+
' method='post'> + +
+
+ <% end %> + + " + 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 = " + + + #{mention o["actor"]} #{o["object"]["published"]} +

#{o["object"]["content"]} + " + if o["object"]["attachment"] + o["object"]["attachment"].each do |att| + case att["mediaType"] + when /audio/ + html<< "\n
" + when /image/ + html << "\n
" + when /video/ + html<< "\n
" + else + html<< att + "
" + html << "\n#{att["url"]}" + end + end + end + end + html << "\n\t\n" + 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("
") + 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 diff --git a/social b/social deleted file mode 100755 index db1685c..0000000 --- a/social +++ /dev/null @@ -1,49 +0,0 @@ -#!/usr/bin/env ruby -require 'net/http' -require 'uri' - -USER = "pdp8" -WWW_DOMAIN = "pdp8.info" -SOCIAL_DOMAIN = "social.#{WWW_DOMAIN}" -SOCIAL_URL = "https://#{SOCIAL_DOMAIN}" - -def post path, body - uri = URI.parse(File.join SOCIAL_URL, path) - http = Net::HTTP.new(uri.host, uri.port) - http.use_ssl = true - header = { 'Content-Type' => 'text/plain' } - request = Net::HTTP::Post.new(uri.request_uri, header) - usr = File.read(".usr").chomp - pwd = File.read(".pwd").chomp - request.basic_auth(usr, pwd) - request.body = body - response = http.request(request) - # TODO return error if response.code > 400 - puts(response.body, response.code) -end - -def get path - uri = URI.parse(File.join SOCIAL_URL, path) - http = Net::HTTP.new(uri.host, uri.port) - http.use_ssl = true - header = { 'Accept' => 'text/plain' } - request = Net::HTTP::Get.new(uri.request_uri, header) - usr = File.read(".usr").chomp - pwd = File.read(".pwd").chomp - request.basic_auth(usr, pwd) - response = http.request(request) - # TODO return error if response.code > 400 - puts(response.code, response.body) -end - -# cmd = ARGV.shift -case ARGV.shift -when "post" - post "outbox", File.read(ARGV[0]) -when "follow" - post "follow", ARGV.join(" ") -when "unfollow" - post "unfollow", ARGV.join(" ") -when "inbox" - get "inbox" -end -- cgit v1.2.3