# 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 << "
#{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