# TODO
# follow request confirmation
# anchors (return after archive)
# boost
# 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
post "/inbox" do # server-server
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
else
p "Unknown action: #{action['type']}"
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 "/archive/*" do
protected!
FileUtils.mv params['splat'][0], "archive/"
redirect to("/")
end
post "/delete/*" do
protected!
FileUtils.rm params['splat'][0]
redirect to("/")
end
post "/delete" do
protected!
FileUtils.rm Dir["inbox/*.json"]
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 "/archive", :provides => 'html' do
protected!
dir_html "archive"
end
get "/", :provides => 'html' do
protected!
dir_html "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
def verify!
# https://github.com/mastodon/mastodon/blob/main/app/controllers/concerns/signature_verification.rb
# TODO verify digest
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)
end
end
def dir_html dir
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'
{ :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
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{|it| it[:replies] << i}
end
end
html="