From 23d72528a829e080ca6a508a217a700f04217d4a Mon Sep 17 00:00:00 2001 From: pdp8 Date: Sat, 3 Jun 2023 19:34:40 +0200 Subject: sessions and login --- activitypub.rb | 255 ++++++++++++++--------------------- application.rb | 415 --------------------------------------------------------- config.ru | 6 - 3 files changed, 101 insertions(+), 575 deletions(-) delete mode 100644 application.rb delete mode 100644 config.ru diff --git a/activitypub.rb b/activitypub.rb index c019d6e..05ed6e0 100644 --- a/activitypub.rb +++ b/activitypub.rb @@ -20,7 +20,8 @@ ACCOUNT = "#{USER}@#{SOCIAL_DOMAIN}" SOCIAL_URL = "https://#{SOCIAL_DOMAIN}" ACTOR = File.join(SOCIAL_URL, USER) -use Rack::Reloader +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 @@ -33,24 +34,30 @@ end post "/inbox" do # server-server request.body.rewind # in case someone already read it body = request.body.read + p body 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" => object } - send_signed accept, accept["object"]["actor"] + "object" => action["object"] } + send_signed accept, action["actor"] + when "Undo" o = action["object"] case o["type"] @@ -59,12 +66,14 @@ post "/inbox" do # server-server 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") @@ -107,54 +116,75 @@ post "/outbox" do # client-server recipients.each { |r| send_signed create, r } end +post "/delete/*" do + protected! + FileUtils.rm params['splat'][0] + redirect to("/inbox") +end + +post "/follow/*" do + protected! + mention = params['splat'][0] + actor = actor(mention) + p actor + 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("/inbox") +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("/inbox") + end +end + +post "/login" do + session["client"] = true if params["secret"] == File.read(".pwd").chomp + redirect to("/inbox") +end + get "/.well-known/webfinger" do if request["resource"] == "acct:#{ACCOUNT}" send_file "./webfinger", :type => "application/jrd+json" else - 404 + halt 404 end end get "/inbox", :provides => 'html' do - erb " - - - <% Dir['./inbox/*'].sort.each_with_index do |file,i| %> - <% item = JSON.parse(File.read(file)) %> - <%= i+1 %> <%= mention item['attributedTo'] %> <%= 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 %> - - " + protected! + @inbox = Dir['./inbox/*'].sort + p @inbox + erb :inbox +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 delete object @@ -183,7 +213,7 @@ def get url, accept = 'application/ld+json; profile="https://www.w3.org/ns/activ end def ordered_collection dir - posts = Dir[File.join(dir, "*.json")].collect { |f| JSON.parse(File.read f) }.sort_by { |o| o["published"] } + 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}", @@ -224,36 +254,43 @@ def send_signed object, url end def inbox uri + p "INBOX" + p uri + p get(uri) URI(get(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 - signature_params = {} - env["HTTP_SIGNATURE"].split(',').each do |pair| - k, v = pair.split('=') - signature_params[k] = v.gsub('"', '') - end + 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']) + 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']) + 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") + 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) + key.verify(OpenSSL::Digest.new('SHA256'), signature, comparison) + rescue + false + end end def actor mention @@ -264,93 +301,3 @@ def actor mention l["rel"] == "self" }[0]["href"] end - -=begin - -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 %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" - 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 - - - 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 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/application.rb b/application.rb deleted file mode 100644 index c8a657c..0000000 --- a/application.rb +++ /dev/null @@ -1,415 +0,0 @@ -# TODO -# unwrap and save object from create -# boost -# archive -# threads -# federation -# client post media -# 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 } - # File.open(File.join("inbox", input["published"] + ".json"), "w+") { |f| f.puts input["object"] } - 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 - 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) - case env["HTTP_ACCEPT"] - when /json/ - response = ordered_collection(env["REQUEST_PATH"]).to_json - 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 - html = "\n\n\t" - Dir[File.join(path.sub(/^\//, ''), "*")].sort_by { |f| File.stat(f).ctime }.each do |file| - item = JSON.parse(File.read(file)) - html << "\n\t\t#{mention item["actor"]} #{item["object"]["published"].sub("T", - " ")}

#{item["object"]["content"]}" - if item["object"]["attachment"] - item["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 - html << "

-

- -
-
- -
-
- -
-
- -
-
" - end - html << "\n\t\n" - end -=begin -=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 - - 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 mention actor - "#{get(actor)["preferredUsername"]}@#{URI(actor).host}" - 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 ordered_collection dir - collection = dir.sub(/^\//, "") - posts = Dir[File.join(collection, "*.json")].collect { |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] - true - end -end diff --git a/config.ru b/config.ru deleted file mode 100644 index fc37641..0000000 --- a/config.ru +++ /dev/null @@ -1,6 +0,0 @@ -require_relative './application.rb' -require 'rack/protection' -use Rack::Protection, :except => :session_hijacking -use Rack::Reloader -use Rack::Static, :urls => ["/create", "/outbox", "/following", "/followers", "/likes", "/shares"], :cascade => true -run Application.new -- cgit v1.2.3