diff options
-rw-r--r-- | activitypub.rb | 19 | ||||
-rw-r--r-- | client.rb | 105 | ||||
-rw-r--r-- | helpers.rb | 89 | ||||
-rw-r--r-- | public/pdp8 | 4 | ||||
-rw-r--r-- | server.rb | 55 | ||||
-rw-r--r-- | views/collection.erb | 9 | ||||
-rw-r--r-- | views/object.erb | 35 |
7 files changed, 160 insertions, 156 deletions
diff --git a/activitypub.rb b/activitypub.rb index eefae9b..93bb45a 100644 --- a/activitypub.rb +++ b/activitypub.rb @@ -5,25 +5,26 @@ require 'base64' require 'digest/sha2' require 'sinatra' -USER = 'pdp8' -WWW_DOMAIN = 'pdp8.info' -WWW_URL = "https://#{WWW_DOMAIN}" -SOCIAL_DOMAIN = "social.#{WWW_DOMAIN}" SOCIAL_DIR = '/srv/social/' -INBOX = File.join(SOCIAL_DIR, 'inbox') +INBOX_DIR = File.join(SOCIAL_DIR, 'inbox') +# ARCHIVE_DIR = File.join(SOCIAL_DIR, 'archive') PUBLIC_DIR = File.join(SOCIAL_DIR, 'public') -OUTBOX = File.join(PUBLIC_DIR, 'outbox') +OUTBOX_DIR = File.join(PUBLIC_DIR, 'outbox') FOLLOWERS = File.join(PUBLIC_DIR, 'followers') -FOLLOWING = File.join(PUBLIC_DIR, 'following') +FOLLOWING_DIR = File.join(PUBLIC_DIR, 'following') TAGS = File.join(PUBLIC_DIR, 'tags') -ACCOUNT = "#{USER}@#{SOCIAL_DOMAIN}" +USER = 'pdp8' +SOCIAL_DOMAIN = 'social.pdp8.info' +MENTION = "#{USER}@#{SOCIAL_DOMAIN}" + SOCIAL_URL = "https://#{SOCIAL_DOMAIN}" ACTOR = File.join(SOCIAL_URL, USER) +OUTBOX_URL = File.join(SOCIAL_URL, 'outbox') +FOLLOWING_URL = File.join(SOCIAL_URL, 'following') enable :sessions set :session_secret, File.read('.secret').chomp -# set :default_content_type, 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"' set :default_content_type, 'application/activity+json' set :port, 9292 @@ -3,14 +3,10 @@ # client-server post '/' do protected! - date = Time.now.strftime('%Y-%m-%dT%H:%M:%S') - outbox_path = File.join('public/outbox', "#{date}.json") - notes_path = File.join('public/notes', "#{date}.json") + date = Time.now.strftime('%Y-%m-%dT%H:%M:%S.%N') - recipients = ['https://www.w3.org/ns/activitystreams#Public', params[:to]] - recipients += Dir[File.join('public/followers', '*.json')].collect { |f| JSON.parse(File.read(f))['actor'] } - recipients.delete ACTOR - recipients.uniq! + recipients = public + recipients << params[:to] content = [] attachment = [] @@ -45,99 +41,68 @@ post '/' do end end - 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, notes_path), - 'type' => 'Note', - 'attributedTo' => ACTOR, - 'inReplyTo' => params[:inReplyTo], - 'published' => date, - 'content' => "<p>\n#{content.join("\n<br>")}\n</p>", - 'attachment' => attachment, - 'tag' => tag, - 'to' => recipients - }, - 'published' => date, + object = { + 'type' => 'Note', + 'attributedTo' => ACTOR, + 'inReplyTo' => params[:inReplyTo], + 'content' => "<p>\n#{content.join("\n<br>")}\n</p>", + 'attachment' => attachment, + 'tag' => tag, 'to' => recipients } - File.open(outbox_path, 'w+') { |f| f.puts create.to_json } - File.open(notes_path, 'w+') { |f| f.puts create['object'].to_json } - tag.each do |t| - dir = File.join('public', 'tags', t['name'].sub('#', '')) - FileUtils.mkdir_p dir - FileUtils.ln_s File.join('/srv/social/', notes_path), dir - end - - # recipients.delete "https://www.w3.org/ns/activitystreams#Public" - # recipients.each { |r| send_signed create, r } - # send_signed create # , r } - outbox create - redirect params['redirect'] + outbox 'Create', object, recipients, true + redirect(params['anchor'] || '/inbox') end -post '/archive' do +post '/share' do protected! - FileUtils.mv params['file'], 'archive/' - redirect to(params['redirect']) + selection(params).each { |f| FileUtils.mv f, f.sub(%r{/inbox/}, '/shared/') } + outbox 'Announce', params['id'], public + redirect(params['anchor'] || '/inbox') end -post '/delete' do # delete not supported by html forms +post '/delete' do protected! - FileUtils.rm_f(params['file'] || Dir['inbox/*.json']) - redirect(params['redirect'] || '/') + selection(params).each { |f| FileUtils.rm f } + redirect(params['anchor'] || '/inbox') end post '/follow' do protected! actor, mention = parse_follow params['follow'] - follow = { '@context' => 'https://www.w3.org/ns/activitystreams', - 'id' => File.join(SOCIAL_URL, 'following', "#{mention}.json"), - 'type' => 'Follow', - 'actor' => ACTOR, - 'object' => actor, - 'to' => [actor] } - send_signed follow # , actor - redirect '/' + outbox 'Follow', actor, [actor] + redirect(params['anchor'] || '/inbox') end post '/unfollow' do protected! actor, mention = parse_follow params['follow'] - following_path = File.join('public', 'following', "#{mention}.json") + following_path = File.join(FOLLOWING_DIR, "#{mention}.json") if File.exist?(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)), - 'to' => [actor] } - send_signed undo # , actor - FileUtils.rm following_path - redirect '/' + outbox 'Undo', JSON.parse(File.read(following_path)), [actor] + FileUtils.rm_f following_path + redirect(params['anchor'] || '/inbox') end end post '/login' do session['client'] = (OpenSSL::Digest::SHA256.base64digest(params['secret']) == File.read('.digest').chomp) - redirect '/' + redirect '/inbox' end get '/' do + protected! redirect '/inbox' end -['/inbox', '/archive', '/outbox'].each do |path| +['/inbox', '/shared', '/outbox'].each do |path| get path, provides: 'html' do protected! @dir = path.sub('/', '') collection = ordered_collection(File.join(SOCIAL_DIR, path, 'note'))['orderedItems'] @threads = [] - collection.each do |object| + collection.each_with_index do |object, _idx| object['indent'] = 0 object['replies'] = [] if object['inReplyTo'].nil? || collection.select { |o| o['id'] == object['inReplyTo'] }.empty? @@ -158,6 +123,18 @@ helpers do halt 403 unless session['client'] end + def selection(params) + selection = Dir[File.join(SOCIAL_DIR, params['dir'], '*', '*.json')] + params['id'] ? selection.select { |f| JSON.parse(File.read(f))['id'] == params['id'] } : selection + end + + def public + recipients = ['https://www.w3.org/ns/activitystreams#Public'] + recipients += Dir[File.join(FOLLOWERS, '*.json')].collect { |f| JSON.parse(File.read(f))['actor'] } + recipients.delete ACTOR + recipients.uniq + end + def parse_follow(follow) case follow when /^#/ @@ -1,6 +1,7 @@ # frozen_string_literal: true require 'English' + helpers do def curl(ext, url) p url @@ -8,7 +9,6 @@ helpers do $CHILD_STATUS.success? ? response : nil end - # def fetch(url, accept = 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"') def fetch(url, accept = 'application/activity+json') response = curl("-H 'Accept: #{accept}'", url) response ? JSON.parse(response) : nil @@ -16,36 +16,60 @@ helpers do # https://github.com/mastodon/mastodon/blob/main/app/lib/request.rb # , url - def send_signed(object) + def outbox(type, object, recipients, add_recipients = false) + activity = { + '@context' => 'https://www.w3.org/ns/activitystreams', + 'type' => type, + 'actor' => ACTOR, + 'object' => object + } + + now = Time.now + date = now.strftime('%Y-%m-%dT%H:%M:%S.%N') + httpdate = now.utc.httpdate + basename = "#{date}.json" + activity['id'] = File.join(OUTBOX_URL, basename) + activity['published'] = httpdate + if activity['object'] and activity['object']['type'] and !activity['object']['id'] + rel_path = File.join activity['object']['type'].downcase, basename + activity['object']['published'] = httpdate + activity['object']['id'] = File.join(OUTBOX_URL, rel_path) + File.open(File.join(OUTBOX_DIR, rel_path), 'w+') { |f| f.puts activity.to_json } + end + File.open(File.join(OUTBOX_DIR, basename), 'w+') { |f| f.puts activity.to_json } + keypair = OpenSSL::PKey::RSA.new(File.read('private.pem')) - date = Time.now.utc.httpdate - body = object.to_json + body = activity.to_json sha256 = OpenSSL::Digest.new('SHA256') digest = "SHA-256=#{sha256.base64digest(body)}" - recipients = [object['to'], object['cc'], object['bto'], object['bcc'], object['audience']].flatten.compact.uniq - recipients.each do |url| - next if url == 'https://www.w3.org/ns/activitystreams#Public' - - host = URI.parse(url).host - inbox = fetch(url)['inbox'] - if inbox - request_uri = URI(inbox).request_uri - - signed_string = "(request-target): post #{request_uri}\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}\"" - - curl( - "-X POST -H 'Content-Type: application/activity+json' -H 'Host: #{host}' -H 'Date: #{date}' -H 'Digest: #{digest}' -H 'Signature: #{signed_header}' -d '#{body}'", inbox - ) - else - p "No inbox for #{url}" - end + + # assumes that recipient collections have been expanded by sender + # put all recipients into 'to', avoid 'cc' 'bto' 'bcc' 'audience' !! + activity['to'] = recipients if add_recipients + inboxes = if recipients.include? 'https://www.w3.org/ns/activitystreams#Public' + people.collect { |p| p[2] }.uniq # cached sharedInboxes + else + [] + end + recipients.uniq.each do |url| + next if [ACTOR, 'https://www.w3.org/ns/activitystreams#Public'].include? url + + actor = fetch url + next unless actor and actor['inbox'] + + inbox = actor['endpoints']['sharedInbox'] + inboxes << (inbox || actor['inbox']) end - end + inboxes.compact.uniq.each do |inbox| + uri = URI(inbox) + string = "(request-target): post #{uri.request_uri}\nhost: #{uri.host}\ndate: #{httpdate}\ndigest: #{digest}\ncontent-type: application/activity+json" + signature = Base64.strict_encode64(keypair.sign(OpenSSL::Digest.new('SHA256'), string)) + signed_header = "keyId=\"#{ACTOR}#main-key\",algorithm=\"rsa-sha256\",headers=\"(request-target) host date digest content-type\",signature=\"#{signature}\"" - def people - File.read('cache/people.tsv').split("\n").collect { |l| l.chomp.split("\t") } + curl( + "-X POST -H 'Content-Type: application/activity+json' -H 'Host: #{uri.host}' -H 'Date: #{httpdate}' -H 'Digest: #{digest}' -H 'Signature: #{signed_header}' -d '#{body}'", inbox + ) + end end def mention(actor) @@ -55,7 +79,7 @@ helpers do return nil unless a mention = "#{a['preferredUsername']}@#{URI(actor).host}" - File.open('cache/people.tsv', 'a') { |f| f.puts "#{mention}\t#{actor}" } + cache mention, actor, a mention else person[0][0] @@ -73,10 +97,19 @@ helpers do actor = a['links'].select do |l| l['rel'] == 'self' end[0]['href'] - File.open('cache/people.tsv', 'a') { |f| f.puts "#{mention}\t#{actor}" } + cache mention, actor, a actor else actors[0][1] end end + + def people + File.read('cache/people.tsv').split("\n").collect { |l| l.chomp.split("\t") } + end + + def cache(mention, actor, a) + sharedInbox = a['endpoints']['sharedInbox'] if a['endpoints'] and a['endpoints']['sharedInbox'] + File.open('cache/people.tsv', 'a') { |f| f.puts "#{mention}\t#{actor}\t#{sharedInbox}" } + end end diff --git a/public/pdp8 b/public/pdp8 index fafc4e0..36c4ac9 100644 --- a/public/pdp8 +++ b/public/pdp8 @@ -10,6 +10,7 @@ "outbox": "https://social.pdp8.info/outbox", "following": "https://social.pdp8.info/following", "followers": "https://social.pdp8.info/followers", + "manuallyApprovesFollowers": false, "icon": { "type": "Image", "url": "https://pdp8.info/pdp8.png" @@ -31,6 +32,9 @@ "value": "<a rel=\"me\" href=\"https://matrix.to/#/@pdp8:matrix.pdp8.info\">@pdp8:matrix.pdp8.info</a>" } ], + "endpoints": { + "sharedInbox": "https://social.pdp8.info/inbox" + } "publicKey": { "id": "https://social.pdp8.info/pdp8#main-key", "owner": "https://social.pdp8.info/pdp8", @@ -4,21 +4,26 @@ post '/inbox' do request.body.rewind # in case someone already read it @body = request.body.read - unless @body.empty? + halt 400 if @body.empty? + begin @activity = JSON.parse @body - @object = @activity['object'] - @object = fetch(@object) if @object.is_a?(String) && @object.match(/^http/) - halt 400 unless @object + rescue StandardError + p @body + halt 400 end - verify! type = @activity['type'].downcase.to_sym p type - respond_to?(type) ? send(type) : p("Unknown activity: #{type}") + halt 501 unless respond_to?(type) + @object = @activity['object'] + @object = fetch(@object) if @object.is_a?(String) && @object.match(/^http/) + halt 400 unless @object + verify! + send(type) end # public get '/.well-known/webfinger' do - if request['resource'] == "acct:#{ACCOUNT}" + if request['resource'] == "acct:#{MENTION}" send_file('./public/webfinger', type: 'application/jrd+json') else @@ -27,14 +32,9 @@ get '/.well-known/webfinger' do end get '/outbox' do - ordered_collection(OUTBOX).to_json + ordered_collection(OUTBOX_DIR).to_json end -# get '/inbox' do -# protected! -# ordered_collection(File.join(INBOX, 'note')).to_json -# end - ['/following', '/followers'].each do |path| get path do ordered_collection(File.join(PUBLIC_DIR, path)).to_json @@ -93,36 +93,19 @@ helpers do create if @object end - def delete - Dir['inbox/*/*.json'].each do |file| - FileUtils.rm file if JSON.parse(File.read(file))['id'] == @object['id'] - end - end - - def update - delete - create - end - def announce create end def follow File.open(File.join(FOLLOWERS, "#{mention(@activity['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' => @activity, - 'to' => [@activity['actor']] } - send_signed accept + outbox 'Accept', @activity, [@activity['actor']] end def accept return unless @object['type'] == 'Follow' - File.open(File.join(FOLLOWING, "#{mention(@object['object'])}.json"), 'w+') { |f| f.puts @object.to_json } + File.open(File.join(FOLLOWING_DIR, "#{mention(@object['object'])}.json"), 'w+') { |f| f.puts @object.to_json } end def undo @@ -133,14 +116,8 @@ helpers do end end - # when "Like" - # when "Move" - # when "Add" - # when "Remove" - # when "Block" - def inbox - Dir[File.join(INBOX, '*', '*.json')].collect do |file| + Dir[File.join(INBOX_DIR, 'note', '*.json')].collect do |file| JSON.parse(File.read(file)) end.sort_by { |o| o['published'] } end diff --git a/views/collection.erb b/views/collection.erb index 8dd3878..4cc449f 100644 --- a/views/collection.erb +++ b/views/collection.erb @@ -5,7 +5,7 @@ </head> <body> <h1><%= @dir %> - <% dirs = ['inbox','outbox','archive'] + <% dirs = ['inbox','outbox','shared'] dirs.delete(@dir) dirs.each do |d| %> <form action='/<%= d %>' method='get'> @@ -13,13 +13,18 @@ </form> <% end %> </h1> - <% @threads.each do |object| + <% @idx = 0 + @threads.each do |object| @object = object %> <%= erb :object %> <% end %> + <% unless @dir == 'shared' %> <form action='/delete' method='post'> + <input type='hidden' name='dir' value='/<%= @dir %>' /> + <input type='hidden' name='anchor' value='/<%= @dir %>' /> <button>Delete all</button> </form> + <% end %> </body> <script> const reply_buttons = document.querySelectorAll(".reply"); diff --git a/views/object.erb b/views/object.erb index 744a518..3e6e617 100644 --- a/views/object.erb +++ b/views/object.erb @@ -1,26 +1,31 @@ - -<% mention = mention @object['attributedTo'] - following_path = File.join(FOLLOWING, "#{mention}.json") - follow = File.exist?(following_path) ? 'unfollow' : 'follow' +<% @idx +=1 + mention = mention @object['attributedTo'] + follow = File.exist?(File.join(FOLLOWING_DIR, "#{mention}.json")) ? 'unfollow' : 'follow' %> -<div style='margin-left:<%= @object['indent']%>em' id='<%= @object['id'] %>'> +<div style='margin-left:<%= @object['indent']%>em' id='<%= @idx %>'> <b><a href='<%= @object['attributedTo'] %>', target='_blank'><%= mention %></a></b> <form action='/<%= follow %>' method='post'> <input type='hidden' name='follow' value='<%= @object['attributedTo'] %>' /> - <input type='hidden' name='redirect' value='/#<%= @object['id'] %>' /> + <input type='hidden' name='anchor' value='/<%= @dir %>#<%= @idx %>' /> <button><%= follow.capitalize %></button> </form> + <em><%= @object['published'] %></em> + <form action='/delete' method='post'> <input type='hidden' name='id' value='<%= @object['id'] %>' /> - <input type='hidden' name='redirect' value='/#<%= @object['id'] %>' /> + <input type='hidden' name='dir' value='<%= @dir %>' /> + <input type='hidden' name='anchor' value='/<%= @dir %>#<%= @idx %>' /> <button>Delete</button> </form> - <form action='/like' method='post'> + <% unless @dir == 'shared' %> + <form action='/share' method='post'> <input type='hidden' name='id' value='<%= @object['id'] %>' /> - <input type='hidden' name='redirect' value='/#<%= @object['id'] %>' /> - <button>Like</button> + <input type='hidden' name='dir' value='<%= @dir %>' /> + <input type='hidden' name='anchor' value='/<%= @dir %>#<%= @idx %>' /> + <button>Share</button> + <% end %> </form> <%= @object['content'] %> <% if @object['attachment'] @@ -39,16 +44,18 @@ <% end %> <% end %> <p> - <button class='reply' data-index='<%= @object['id'] %>'>Reply</button> - <form action='/' method='post' id='form<%= @object['id'] %>' style='display:none;' > + <% unless @dir == 'shared' %> + <button class='reply' data-index='<%= @idx %>'>Reply</button> + <form action='/' method='post' id='form<%= @idx %>' style='display:none;' > <input type='hidden' name='to' value='<%= @object['attributedTo'] %>' /> <input type='hidden' name='inReplyTo' value='<%= @object['id'] %>' /> - <input type='hidden' name='redirect' value='/#<%= @object['id'] %>' /> + <input type='hidden' name='anchor' value='/<%= @dir %>#<%= @idx %>' /> <textarea name='content'></textarea> <br> - <button class='cancel' data-index='<%= @object['id'] %>'>Cancel</button> + <button class='cancel' data-index='<%= @idx %>'>Cancel</button> <input type='submit' value='Send'> </form> + <% end %> </div> <% @object['replies'].each do |reply| @object = reply %> |