summary refs log tree commit diff
diff options
context:
space:
mode:
authorpdp8 <pdp8@pdp8.info>2023-07-01 00:57:48 +0200
committerpdp8 <pdp8@pdp8.info>2023-07-01 00:57:48 +0200
commit5af8d78e195c7479769240b32703d5b76843db4d (patch)
treee9d145e1318e3c47b5b2b00ce276703a14edc958
parent8dd940a1c08b02ed1ea613284b627e1f46fb13c9 (diff)
initial refactoring of client.rb
-rw-r--r--activitypub.rb2
-rw-r--r--client.rb201
-rw-r--r--helpers.rb82
-rw-r--r--server.rb35
-rw-r--r--views/collection.erb (renamed from views/index.erb)13
-rw-r--r--views/item.erb54
-rw-r--r--views/object.erb57
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'
diff --git a/client.rb b/client.rb
index abf565d..b62f139 100644
--- a/client.rb
+++ b/client.rb
@@ -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
diff --git a/helpers.rb b/helpers.rb
index a51ea3d..fdbd894 100644
--- a/helpers.rb
+++ b/helpers.rb
@@ -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
diff --git a/server.rb b/server.rb
index 282c3e9..6379332 100644
--- a/server.rb
+++ b/server.rb
@@ -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>&nbsp;
-  <% 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>
-    &nbsp;
-    <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' %>
-      &nbsp;
-      <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>&nbsp;
+  <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>
+  &nbsp;
+  <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>
+  &nbsp;
+  <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 %>
+