diff options
-rw-r--r-- | activitypub.rb | 2 | ||||
-rw-r--r-- | client.rb | 201 | ||||
-rw-r--r-- | helpers.rb | 82 | ||||
-rw-r--r-- | server.rb | 35 | ||||
-rw-r--r-- | views/collection.erb (renamed from views/index.erb) | 13 | ||||
-rw-r--r-- | views/item.erb | 54 | ||||
-rw-r--r-- | views/object.erb | 57 |
7 files changed, 217 insertions, 227 deletions
diff --git a/activitypub.rb b/activitypub.rb index ae3c270..eefae9b 100644 --- a/activitypub.rb +++ b/activitypub.rb @@ -28,5 +28,5 @@ set :default_content_type, 'application/activity+json' set :port, 9292 require_relative 'helpers' -require_relative 'server' require_relative 'client' +require_relative 'server' @@ -1,28 +1,30 @@ +# frozen_string_literal: true + # client-server -post "/" do +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') + outbox_path = File.join('public/outbox', "#{date}.json") + notes_path = File.join('public/notes', "#{date}.json") - 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 = ['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! - + content = [] attachment = [] tag = [] extensions = { - :image => ["jpeg","png"], - :audio => ["flac","wav","mp3","ogg"], - :video => ["mp4","webm"] + image: %w[jpeg png], + audio: %w[flac wav mp3 ogg], + video: %w[mp4 webm] } params[:content].lines.each do |line| line.chomp! if line.match(/^http/) - ext = File.extname(line).sub('.','') - media_type = extensions.select{|k,v| v.include? ext}.keys[0].to_s + '/' + ext + ext = File.extname(line).sub('.', '') + media_type = extensions.select { |_k, v| v.include? ext }.keys[0].to_s + '/' + ext attachment << { 'type' => 'Document', 'mediaType' => media_type, @@ -31,7 +33,7 @@ post "/" do else tags = line.split(/\s+/).grep(/^#\w+$/) tags.each do |name| - href = File.join(SOCIAL_URL,'tags',name.sub('#','')) + href = File.join(SOCIAL_URL, 'tags', name.sub('#', '')) tag << { 'type' => 'Hashtag', 'href' => href, @@ -44,161 +46,129 @@ post "/" do 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 + '@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, - "to" => recipients + 'published' => date, + '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 } + 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('#','')) + dir = File.join('public', 'tags', t['name'].sub('#', '')) FileUtils.mkdir_p dir - FileUtils.ln_s File.join('/srv/social/',notes_path), 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 } + # 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'] end -post "/archive" do +post '/archive' do protected! - FileUtils.mv params['file'], "archive/" + FileUtils.mv params['file'], 'archive/' redirect to(params['redirect']) end -post "/delete" do # delete not supported by html forms +post '/delete' do # delete not supported by html forms protected! - params['file'] ? FileUtils.rm_f(params['file']) : FileUtils.rm_f(Dir["inbox/*.json"]) - params['redirect'] ? redirect(params['redirect']) : redirect('/') + FileUtils.rm_f(params['file'] || Dir['inbox/*.json']) + redirect(params['redirect'] || '/') end -post "/follow" do +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 "/" + 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 '/' end -post "/unfollow" do +post '/unfollow' do protected! actor, mention = parse_follow params['follow'] - 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)), - 'to' => [ actor ] } - send_signed undo#, actor + following_path = File.join('public', 'following', "#{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 "/" + redirect '/' end end -post "/login" do - session["client"] = true if OpenSSL::Digest::SHA256.base64digest(params["secret"]) == File.read(".digest").chomp - redirect "/" +post '/login' do + session['client'] = (OpenSSL::Digest::SHA256.base64digest(params['secret']) == File.read('.digest').chomp) + redirect '/' +end + +get '/' do + redirect '/inbox' end -# private -["/", "/archive"].each do |path| - get path, :provides => 'html' do +['/inbox', '/archive', '/outbox'].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 + @items = fetch(File.join(SOCIAL_URL, path))['orderedItems'] threads - erb :index + erb :collection end end helpers do - def protected! halt 403 unless session['client'] end - def items - nr = 0 - p @dir - p Dir[File.join(@dir, '*', '*.json')]# + Dir['public/notes/*.json'] - files = Dir[File.join(@dir, '*', '*.json')] + Dir['public/notes/*.json'] - @items = files.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 => 0, - :replies => [] - } - end.compact - @items.last[:nr] = @items.last[:nr] - 2 unless @items.empty? + def outbox(activity) + curl("-X POST -d #{activity.to_json}", File.join(SOCIAL_URL, 'outbox')) end def threads - items @threads = [] @items.each do |i| - if i[:parent].nil? or @items.select{|it| it[:id] == i[:parent] }.empty? + i['indent'] = 0 + i['replies'] = [] + if i['inReplyTo'].nil? || @items.select { |it| it['id'] == i['inReplyTo'] }.empty? @threads << i else - @items.select{|it| it[:id] == i[:parent] }.each do |it| - i[:indent] = it[:indent] + 4 - it[:replies] << i + @items.select { |it| it['id'] == i['inReplyTo'] }.each do |it| + i['indent'] = it['indent'] + 4 + it['replies'] << i end end end end - def html item - @item = item - erb :item - end - - def parse_follow follow + def parse_follow(follow) case follow when /^#/ - actor = "https://relay.fedi.buzz/tag/#{follow.sub(/^#/,'')}" + actor = "https://relay.fedi.buzz/tag/#{follow.sub(/^#/, '')}" mention = follow when /^http/ actor = follow @@ -210,5 +180,4 @@ helpers do end [actor, mention] end - end @@ -1,78 +1,82 @@ -helpers do +# frozen_string_literal: true - def curl ext, url +require 'English' +helpers do + def curl(ext, url) + p url response = `/run/current-system/sw/bin/curl --fail-with-body -sSL #{ext} #{url}` - $?.success? ? response : nil + $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/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 end # https://github.com/mastodon/mastodon/blob/main/app/lib/request.rb - def send_signed object#, url - + # , url + def send_signed(object) keypair = OpenSSL::PKey::RSA.new(File.read('private.pem')) date = Time.now.utc.httpdate body = object.to_json - sha256 = OpenSSL::Digest::SHA256.new - digest = "SHA-256=" + sha256.base64digest(body) - jj object - #(object['to'] + object['cc'] + object['bto'] + object['bcc']).uniq.each do |url| - object['to'].uniq.each do |url| - unless 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 + 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 + '"' + 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}\"" - p 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) - #$?.success? - else - p "No inbox for #{url}" - end + 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 end - end def people - File.read('cache/people.tsv').split("\n").collect {|l| l.chomp.split("\t")} + File.read('cache/people.tsv').split("\n").collect { |l| l.chomp.split("\t") } end - def mention actor - person = people.select{|p| p[1] == actor} + def mention(actor) + person = people.select { |p| p[1] == actor } if person.empty? a = fetch(actor) return nil unless a - mention = "#{a["preferredUsername"]}@#{URI(actor).host}" - File.open('cache/people.tsv','a'){|f| f.puts "#{mention}\t#{actor}"} + + mention = "#{a['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 + def actor(mention) mention = mention.sub(/^@/, '').chomp - actors = people.select{|p| p[0] == mention} + actors = people.select { |p| p[0] == mention } if actors.empty? - user, server = mention.split("@") - a = fetch("https://#{server}/.well-known/webfinger?resource=acct:#{mention}", "application/jrd+json") + _, server = mention.split('@') + a = fetch("https://#{server}/.well-known/webfinger?resource=acct:#{mention}", 'application/jrd+json') return nil unless a - actor = a["links"].select { |l| - l["rel"] == "self" - }[0]["href"] - File.open('cache/people.tsv','a'){|f| f.puts "#{mention}\t#{actor}"} + + actor = a['links'].select do |l| + l['rel'] == 'self' + end[0]['href'] + File.open('cache/people.tsv', 'a') { |f| f.puts "#{mention}\t#{actor}" } actor else actors[0][1] end end - end @@ -3,9 +3,12 @@ before '/inbox' do request.body.rewind # in case someone already read it @body = request.body.read - @activity = JSON.parse @body - @object = @activity['object'] - @object = fetch(@object) if @object.is_a?(String) && @object.match(/^http/) + unless @body.empty? + @activity = JSON.parse @body + @object = @activity['object'] + @object = fetch(@object) if @object.is_a?(String) && @object.match(/^http/) + halt 400 unless @object + end end # client-server @@ -17,6 +20,8 @@ end # server-server post '/inbox' do verify! + # file = File.join INBOX, "#{SecureRandom.uuid}.json" + # File.open(file, 'w+') { |f| f.puts @activity.to_json } type = @activity['type'].downcase.to_sym respond_to?(type) ? send(type) : p("Unknown activity: #{type}") end @@ -31,7 +36,16 @@ get '/.well-known/webfinger' do end end -['/outbox', '/following', '/followers'].each do |path| +get '/outbox' do + ordered_collection(OUTBOX).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 end @@ -44,11 +58,12 @@ end helpers do # https://github.com/mastodon/mastodon/blob/main/app/controllers/concerns/signature_verification.rb def verify! - # verify digest + # digest sha256 = OpenSSL::Digest.new('SHA256') digest = "SHA-256=#{sha256.base64digest(@body)}" halt 403 unless digest == request.env['HTTP_DIGEST'] + # signature signature_params = {} request.env['HTTP_SIGNATURE'].split(',').each do |pair| k, v = pair.split('=') @@ -60,9 +75,8 @@ helpers do signature = Base64.decode64(signature_params['signature']) actor = fetch key_id - halt 200 if !actor && (@activity['type'] == 'Delete') # deleted users do not return actors - halt 403 unless actor + key = OpenSSL::PKey::RSA.new(actor['publicKey']['publicKeyPem']) comparison = headers.split(' ').map do |signed_params_name| @@ -79,10 +93,11 @@ helpers do end def create + return unless @object return if object_exists? File.open(object_file, 'w+') { |f| f.puts @object.to_json } - return unless @object && @object['inReplyTo'] + return unless @object['inReplyTo'] @object = fetch @object['inReplyTo'] create if @object @@ -125,13 +140,13 @@ helpers do 'actor' => ACTOR, 'object' => @activity, 'to' => [@activity['actor']] } - send_signed accept # , @activity['actor'] + send_signed accept end + # when "Like" # when "Move" # when "Add" # when "Remove" - # when "Like" # when "Block" def inbox diff --git a/views/index.erb b/views/collection.erb index b512911..4455b31 100644 --- a/views/index.erb +++ b/views/collection.erb @@ -9,14 +9,13 @@ <button><%= @alt_name %></button> </form> </h1> - <% @threads.each do |item| %> - <%= html item %> - <% end %> - <% if @dir == 'inbox' %> - <form action='/delete' method='post'> - <button>Delete all</button> - </form> + <% @threads.each do |object| + @object = object %> + <%= erb :object %> <% end %> + <form action='/delete' method='post'> + <button>Delete all</button> + </form> </body> <script> const reply_buttons = document.querySelectorAll(".reply"); diff --git a/views/item.erb b/views/item.erb deleted file mode 100644 index 8036b39..0000000 --- a/views/item.erb +++ /dev/null @@ -1,54 +0,0 @@ -<div style='margin-left:<%= @item[:indent] %>em' id='<%= @item[:nr] %>'> - <b><a href='<%= @item[:actor_url] %>', target='_blank'><%= @item[:mention] %></a></b> - <% if @item[:mention] != ACCOUNT %> - <form action='/follow' method='post'> - <input type='hidden' name='follow' value='<%= @item[:mention] %>' /> - <input type='hidden' name='redirect' value='/<%= @dir.sub('inbox','') %>#<%= @item[:nr] %>' /> - <button><%= @item[:follow].capitalize %></button> - </form> - - <form action='/delete' method='post'> - <input type='hidden' name='file' value='<%= @item[:file] %>' /> - <input type='hidden' name='redirect' value='/<%= @dir.sub('inbox','') %>#<%= @item[:nr] %>' /> - <button>Delete</button> - </form> - <% if @dir == 'inbox' %> - - <form action='/archive' method='post'> - <input type='hidden' name='file' value='<%= @item[:file] %>' /> - <input type='hidden' name='redirect' value='/<%= @dir.sub('inbox','') %>#<%= @item[:nr] %>' /> - <button>Archive</button> - </form> - <% end %> - <% end %> - <%= @item[:content] %> - <% if @item[:attachment] - @item[:attachment].each do |att| - case att['mediaType'] - when /audio/ %> - <br><audio controls=''><source src='<%= att['url'] %>' type='<%= att['mediaType'] %>'></audio> - <% when /image/ %> - <br><a href='<%= att['url'] %>'><img src='<%= att['url'] %>'></a> - <% when /video/ %> - <br><video controls=''><source src='<%= att['url'] %>' type='<%= att['mediaType'] %>'></video> - <% else %> - <%= att %><br> - <a href='<%= att['url'] %>'><%= att['url'] %></a> - <% end %> - <% end %> - <% end %> - <p> - <button class="reply" data-index='<%= @item[:nr] %>'>Reply</button> - <form action='/' method='post' id='form<%= @item[:nr] %>' style='display:none;' > - <input type='hidden' name='to' value='<%= @item[:actor_url] %>' /> - <input type='hidden' name='inReplyTo' value='<%= @item[:id] %>' /> - <input type='hidden' name='redirect' value='/<%= @dir.sub('inbox','') %>#<%= @item[:nr] %>' /> - <textarea name='content'></textarea> - <br> - <button class="cancel" data-index='<%= @item[:nr] %>'>Cancel</button> - <input type='submit' value='Send'> - </form> -</div> -<% @item[:replies].each do |reply| %> - <%= html reply %> -<% end %> diff --git a/views/object.erb b/views/object.erb new file mode 100644 index 0000000..744a518 --- /dev/null +++ b/views/object.erb @@ -0,0 +1,57 @@ + +<% mention = mention @object['attributedTo'] + following_path = File.join(FOLLOWING, "#{mention}.json") + follow = File.exist?(following_path) ? 'unfollow' : 'follow' +%> +<div style='margin-left:<%= @object['indent']%>em' id='<%= @object['id'] %>'> + <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'] %>' /> + <button><%= follow.capitalize %></button> + </form> + + <form action='/delete' method='post'> + <input type='hidden' name='id' value='<%= @object['id'] %>' /> + <input type='hidden' name='redirect' value='/#<%= @object['id'] %>' /> + <button>Delete</button> + </form> + + <form action='/like' method='post'> + <input type='hidden' name='id' value='<%= @object['id'] %>' /> + <input type='hidden' name='redirect' value='/#<%= @object['id'] %>' /> + <button>Like</button> + </form> + <%= @object['content'] %> + <% if @object['attachment'] + @object['attachment'].each do |att| + case att['mediaType'] + when /audio/ %> + <br><audio controls=''><source src='<%= att['url'] %>' type='<%= att['mediaType'] %>'></audio> + <% when /image/ %> + <br><a href='<%= att['url'] %>'><img src='<%= att['url'] %>'></a> + <% when /video/ %> + <br><video controls=''><source src='<%= att['url'] %>' type='<%= att['mediaType'] %>'></video> + <% else %> + <%= att %><br> + <a href='<%= att['url'] %>'><%= att['url'] %></a> + <% end %> + <% end %> + <% end %> + <p> + <button class='reply' data-index='<%= @object['id'] %>'>Reply</button> + <form action='/' method='post' id='form<%= @object['id'] %>' 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'] %>' /> + <textarea name='content'></textarea> + <br> + <button class='cancel' data-index='<%= @object['id'] %>'>Cancel</button> + <input type='submit' value='Send'> + </form> +</div> +<% @object['replies'].each do |reply| + @object = reply %> + <%= erb :object %> +<% end %> + |