summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--activitypub.rb359
-rw-r--r--client.rb116
-rw-r--r--helpers.rb177
-rw-r--r--public/favicon.icobin0 -> 11566 bytes
-rw-r--r--server.rb76
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
new file mode 100644
index 0000000..23625f9
--- /dev/null
+++ b/public/favicon.ico
Binary files differ
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