From 66e67aa816bbe2d1638dfdd3d7e01876fdd3063a Mon Sep 17 00:00:00 2001
From: Thomas Citharel <tcit@tcit.fr>
Date: Mon, 4 Mar 2019 17:20:18 +0100
Subject: [PATCH] Redirect properly to correct endpoint depending on
 content-type

Signed-off-by: Thomas Citharel <tcit@tcit.fr>
---
 js/public/index.html                          |  1 +
 js/vue.config.js                              | 15 ++++
 lib/mobilizon/actors/actor.ex                 |  6 ++
 lib/mobilizon/actors/actors.ex                | 14 +++
 lib/mobilizon/events/events.ex                | 20 +++++
 .../controllers/activity_pub_controller.ex    | 88 +++++--------------
 .../controllers/page_controller.ex            | 72 +++++++++++++++
 lib/mobilizon_web/router.ex                   | 14 ++-
 lib/service/metadata.ex                       |  6 ++
 lib/service/metadata/actor.ex                 | 25 ++++++
 lib/service/metadata/comment.ex               | 22 +++++
 lib/service/metadata/event.ex                 | 24 +++++
 12 files changed, 237 insertions(+), 70 deletions(-)
 create mode 100644 lib/service/metadata.ex
 create mode 100644 lib/service/metadata/actor.ex
 create mode 100644 lib/service/metadata/comment.ex
 create mode 100644 lib/service/metadata/event.ex

diff --git a/js/public/index.html b/js/public/index.html
index 86e3871f2..346854e2f 100644
--- a/js/public/index.html
+++ b/js/public/index.html
@@ -8,6 +8,7 @@
   <link rel="icon" href="<%= BASE_URL %>favicon.ico">
   <link rel="stylesheet" href="//cdn.materialdesignicons.com/2.5.94/css/materialdesignicons.min.css">
   <title>mobilizon</title>
+  <!--server-generated-meta-->
 </head>
 
 <body>
diff --git a/js/vue.config.js b/js/vue.config.js
index b76081558..b3363d711 100644
--- a/js/vue.config.js
+++ b/js/vue.config.js
@@ -19,4 +19,19 @@ module.exports = {
       ],
     },
   },
+  chainWebpack: config => {
+    config
+        .plugin('html')
+        .tap(args => {
+          args[0].minify = {
+            collapseWhitespace: true,
+            removeComments: false,
+            removeRedundantAttributes: true,
+            removeScriptTypeAttributes: true,
+            removeStyleLinkTypeAttributes: true,
+            useShortDoctype: true
+          };
+          return args
+        });
+  }
 };
diff --git a/lib/mobilizon/actors/actor.ex b/lib/mobilizon/actors/actor.ex
index 5ce7c0db1..9cae6b29e 100644
--- a/lib/mobilizon/actors/actor.ex
+++ b/lib/mobilizon/actors/actor.ex
@@ -378,6 +378,12 @@ defmodule Mobilizon.Actors.Actor do
     end
   end
 
+  def display_name_and_username(%Actor{name: nil} = actor), do: actor_acct_from_actor(actor)
+  def display_name_and_username(%Actor{name: ""} = actor), do: actor_acct_from_actor(actor)
+
+  def display_name_and_username(%Actor{name: name} = actor),
+    do: name <> " (" <> actor_acct_from_actor(actor) <> ")"
+
   def clear_cache(%Actor{preferred_username: preferred_username, domain: nil}) do
     Cachex.del(:activity_pub, "actor_" <> preferred_username)
   end
diff --git a/lib/mobilizon/actors/actors.ex b/lib/mobilizon/actors/actors.ex
index 92a4e40af..181e61137 100644
--- a/lib/mobilizon/actors/actors.ex
+++ b/lib/mobilizon/actors/actors.ex
@@ -467,6 +467,20 @@ defmodule Mobilizon.Actors do
     |> Repo.preload(:organized_events)
   end
 
+  @doc """
+  Returns a cached local actor by username
+  """
+  @spec get_cached_local_actor_by_name(String.t()) ::
+          {:ok, Actor.t()} | {:commit, Actor.t()} | {:ignore, any()}
+  def get_cached_local_actor_by_name(name) do
+    Cachex.fetch(:activity_pub, "actor_" <> name, fn "actor_" <> name ->
+      case get_local_actor_by_name(name) do
+        nil -> {:ignore, nil}
+        %Actor{} = actor -> {:commit, actor}
+      end
+    end)
+  end
+
   @doc """
   Getting an actor from url, eventually creating it
   """
diff --git a/lib/mobilizon/events/events.ex b/lib/mobilizon/events/events.ex
index 6bf3ea1ec..f9fd63770 100644
--- a/lib/mobilizon/events/events.ex
+++ b/lib/mobilizon/events/events.ex
@@ -154,6 +154,16 @@ defmodule Mobilizon.Events do
     |> Repo.one()
   end
 
+  def get_cached_event_full_by_uuid(uuid) do
+    Cachex.fetch(:activity_pub, "event_" <> uuid, fn "event_" <> uuid ->
+      with %Event{} = event <- get_event_full_by_uuid(uuid) do
+        {:commit, event}
+      else
+        _ -> {:ignore, nil}
+      end
+    end)
+  end
+
   @doc """
   Gets a single event, with all associations loaded.
   """
@@ -1018,6 +1028,16 @@ defmodule Mobilizon.Events do
     end
   end
 
+  def get_cached_comment_full_by_uuid("comment_" <> uuid) do
+    Cachex.fetch(:activity_pub, "comment_" <> uuid, fn "comment_" <> uuid ->
+      with %Comment{} = comment <- Events.get_comment_full_from_uuid(uuid) do
+        {:commit, comment}
+      else
+        _ -> {:ignore, nil}
+      end
+    end)
+  end
+
   def get_comment_from_url(url), do: Repo.get_by(Comment, url: url)
 
   def get_comment_from_url!(url), do: Repo.get_by!(Comment, url: url)
diff --git a/lib/mobilizon_web/controllers/activity_pub_controller.ex b/lib/mobilizon_web/controllers/activity_pub_controller.ex
index 76670b69b..870554108 100644
--- a/lib/mobilizon_web/controllers/activity_pub_controller.ex
+++ b/lib/mobilizon_web/controllers/activity_pub_controller.ex
@@ -16,101 +16,55 @@ defmodule MobilizonWeb.ActivityPubController do
 
   action_fallback(:errors)
 
-  @activity_pub_headers [
-    "application/activity+json",
-    "application/activity+json, application/ld+json"
-  ]
-
   @doc """
-  Show an Actor's ActivityPub representation
+  Renders an Actor ActivityPub's representation
   """
-  @spec actor(Plug.Conn.t(), map()) :: Plug.Conn.t()
+  @spec actor(Plug.Conn.t(), String.t()) :: Plug.Conn.t()
   def actor(conn, %{"name" => name}) do
-    if conn |> get_req_header("accept") |> is_ap_header() do
-      render_cached_actor(conn, name)
-    else
+    with {status, %Actor{} = actor} when status in [:ok, :commit] <-
+           Actors.get_cached_local_actor_by_name(name) do
       conn
-      |> put_resp_content_type("text/html")
-      |> send_file(200, "priv/static/index.html")
-    end
-  end
-
-  @spec render_cached_actor(Plug.Conn.t(), String.t()) :: Plug.Conn.t()
-  defp render_cached_actor(conn, name) do
-    case Cachex.fetch(:activity_pub, "actor_" <> name, &get_local_actor_by_name/1) do
-      {status, %Actor{} = actor} when status in [:ok, :commit] ->
-        conn
-        |> put_resp_header("content-type", "application/activity+json")
-        |> json(ActorView.render("actor.json", %{actor: actor}))
-
+      |> put_resp_header("content-type", "application/activity+json")
+      |> json(ActorView.render("actor.json", %{actor: actor}))
+    else
       {:ignore, _} ->
         {:error, :not_found}
     end
   end
 
-  defp get_local_actor_by_name("actor_" <> name) do
-    case Actors.get_local_actor_by_name(name) do
-      nil -> {:ignore, nil}
-      %Actor{} = actor -> {:commit, actor}
-    end
-  end
-
-  # Test if the request has an AP header
-  defp is_ap_header(ap_headers) do
-    length(@activity_pub_headers -- ap_headers) < 2
-  end
-
   @doc """
   Renders an Event ActivityPub's representation
   """
   @spec event(Plug.Conn.t(), map()) :: Plug.Conn.t()
   def event(conn, %{"uuid" => uuid}) do
-    case Cachex.fetch(:activity_pub, "event_" <> uuid, &get_event_full_by_uuid/1) do
-      {status, %Event{} = event} when status in [:ok, :commit] ->
-        conn
-        |> put_resp_header("content-type", "application/activity+json")
-        |> json(ObjectView.render("event.json", %{event: event |> Utils.make_event_data()}))
-
+    with {status, %Event{}} = event when status in [:ok, :commit] <-
+           Events.get_cached_event_full_by_uuid(uuid),
+         true <- event.visibility in [:public, :unlisted] do
+      conn
+      |> put_resp_header("content-type", "application/activity+json")
+      |> json(ObjectView.render("event.json", %{event: event |> Utils.make_event_data()}))
+    else
       {:ignore, _} ->
         {:error, :not_found}
     end
   end
 
-  defp get_event_full_by_uuid("event_" <> uuid) do
-    with %Event{} = event <- Events.get_event_full_by_uuid(uuid),
-         true <- event.visibility in [:public, :unlisted] do
-      {:commit, event}
-    else
-      _ -> {:ignore, nil}
-    end
-  end
-
   @doc """
   Renders a Comment ActivityPub's representation
   """
   @spec comment(Plug.Conn.t(), map()) :: Plug.Conn.t()
   def comment(conn, %{"uuid" => uuid}) do
-    case Cachex.fetch(:activity_pub, "comment_" <> uuid, &get_comment_full_by_uuid/1) do
-      {status, %Comment{} = comment} when status in [:ok, :commit] ->
-        conn
-        |> put_resp_header("content-type", "application/activity+json")
-        |> json(
-          ObjectView.render("comment.json", %{comment: comment |> Utils.make_comment_data()})
-        )
-
-      {:ignore, _} ->
-        {:error, :not_found}
-    end
-  end
-
-  defp get_comment_full_by_uuid("comment_" <> uuid) do
-    with %Comment{} = comment <- Events.get_comment_full_from_uuid(uuid) do
+    with {status, %Comment{} = comment} when status in [:ok, :commit] <-
+           Events.get_cached_comment_full_by_uuid(uuid) do
       # Comments are always public for now
       # TODO : Make comments maybe restricted
       # true <- comment.public do
-      {:commit, comment}
+      conn
+      |> put_resp_header("content-type", "application/activity+json")
+      |> json(ObjectView.render("comment.json", %{comment: comment |> Utils.make_comment_data()}))
     else
-      _ -> {:ignore, nil}
+      {:ignore, _} ->
+        {:error, :not_found}
     end
   end
 
diff --git a/lib/mobilizon_web/controllers/page_controller.ex b/lib/mobilizon_web/controllers/page_controller.ex
index c68f79bf6..a3e755a2e 100644
--- a/lib/mobilizon_web/controllers/page_controller.ex
+++ b/lib/mobilizon_web/controllers/page_controller.ex
@@ -3,6 +3,11 @@ defmodule MobilizonWeb.PageController do
   Controller to load our webapp
   """
   use MobilizonWeb, :controller
+  alias Mobilizon.Service.Metadata
+  alias Mobilizon.Actors
+  alias Mobilizon.Actors.Actor
+  alias Mobilizon.Events
+  alias Mobilizon.Events.{Event, Comment}
 
   plug(:put_layout, false)
 
@@ -11,4 +16,71 @@ defmodule MobilizonWeb.PageController do
     |> put_resp_content_type("text/html")
     |> send_file(200, "priv/static/index.html")
   end
+
+  def actor(conn, %{"name" => name}) do
+    case get_format(conn) do
+      "html" ->
+        with {status, %Actor{} = actor} when status in [:ok, :commit] <-
+               Actors.get_cached_local_actor_by_name(name) do
+          render_with_meta(conn, actor)
+        end
+
+      "activity+json" ->
+        MobilizonWeb.ActivityPubController.call(conn, :actor)
+
+      _ ->
+        {:error, :not_found}
+    end
+  end
+
+  def event(conn, %{"uuid" => uuid}) do
+    case get_format(conn) do
+      "html" ->
+        with {status, %Event{} = event} when status in [:ok, :commit] <-
+               Events.get_cached_event_full_by_uuid(uuid),
+             true <- event.visibility in [:public, :unlisted] do
+          render_with_meta(conn, event)
+        end
+
+      "activity+json" ->
+        MobilizonWeb.ActivityPubController.call(conn, :event)
+
+      _ ->
+        {:error, :not_found}
+    end
+  end
+
+  def comment(conn, %{"uuid" => uuid}) do
+    case get_format(conn) do
+      "html" ->
+        with {status, %Comment{} = comment} when status in [:ok, :commit] <-
+               Events.get_cached_comment_full_by_uuid(uuid) do
+          # Comments are always public for now
+          # TODO : Make comments maybe restricted
+          # true <- comment.public do
+          render_with_meta(conn, comment)
+        end
+
+      "activity+json" ->
+        MobilizonWeb.ActivityPubController.call(conn, :comment)
+
+      _ ->
+        {:error, :not_found}
+    end
+  end
+
+  # Inject OpenGraph information
+  defp render_with_meta(conn, object) do
+    {:ok, index_content} = File.read(index_file_path())
+    tags = Metadata.build_tags(object)
+    response = String.replace(index_content, "<!--server-generated-meta-->", tags)
+
+    conn
+    |> put_resp_content_type("text/html")
+    |> send_resp(200, response)
+  end
+
+  defp index_file_path() do
+    Path.join(Application.app_dir(:mobilizon, "priv/static/"), "index.html")
+  end
 end
diff --git a/lib/mobilizon_web/router.ex b/lib/mobilizon_web/router.ex
index e41a0cdb0..73d996004 100644
--- a/lib/mobilizon_web/router.ex
+++ b/lib/mobilizon_web/router.ex
@@ -19,6 +19,10 @@ defmodule MobilizonWeb.Router do
   end
 
   pipeline :activity_pub do
+    plug(:accepts, ["activity-json"])
+  end
+
+  pipeline :activity_pub_and_html do
     plug(:accepts, ["activity-json", "html"])
   end
 
@@ -61,15 +65,19 @@ defmodule MobilizonWeb.Router do
     get("/@:name/feed/:format", FeedController, :actor)
   end
 
+  scope "/", MobilizonWeb do
+    pipe_through(:activity_pub_and_html)
+    get("/@:name", PageController, :actor)
+    get("/events/:uuid", PageController, :event)
+    get("/comments/:uuid", PageController, :comment)
+  end
+
   scope "/", MobilizonWeb do
     pipe_through(:activity_pub)
 
-    get("/@:name", ActivityPubController, :actor)
     get("/@:name/outbox", ActivityPubController, :outbox)
     get("/@:name/following", ActivityPubController, :following)
     get("/@:name/followers", ActivityPubController, :followers)
-    get("/events/:uuid", ActivityPubController, :event)
-    get("/comments/:uuid", ActivityPubController, :comment)
   end
 
   scope "/", MobilizonWeb do
diff --git a/lib/service/metadata.ex b/lib/service/metadata.ex
new file mode 100644
index 000000000..bd3ba99b1
--- /dev/null
+++ b/lib/service/metadata.ex
@@ -0,0 +1,6 @@
+defprotocol Mobilizon.Service.Metadata do
+  @doc """
+  Build tags
+  """
+  def build_tags(entity)
+end
diff --git a/lib/service/metadata/actor.ex b/lib/service/metadata/actor.ex
new file mode 100644
index 000000000..4a51e2f96
--- /dev/null
+++ b/lib/service/metadata/actor.ex
@@ -0,0 +1,25 @@
+defimpl Mobilizon.Service.Metadata, for: Mobilizon.Actors.Actor do
+  alias Phoenix.HTML
+  alias Phoenix.HTML.Tag
+  alias Mobilizon.Actors.Actor
+  require Logger
+
+  def build_tags(%Actor{} = actor) do
+    actor
+    |> do_build_tags()
+    |> Enum.map(&HTML.safe_to_string/1)
+    |> Enum.reduce("", fn tag, acc -> acc <> tag end)
+  end
+
+  defp do_build_tags(%Actor{} = actor) do
+    [
+      Tag.tag(:meta, property: "og:title", content: Actor.display_name_and_username(actor)),
+      Tag.tag(:meta, property: "og:url", content: actor.url),
+      Tag.tag(:meta, property: "og:description", content: actor.summary),
+      Tag.tag(:meta, property: "og:type", content: "profile"),
+      Tag.tag(:meta, property: "profile:username", content: actor.preferred_username),
+      Tag.tag(:meta, property: "og:image", content: actor.avatar_url),
+      Tag.tag(:meta, property: "twitter:card", content: "summary")
+    ]
+  end
+end
diff --git a/lib/service/metadata/comment.ex b/lib/service/metadata/comment.ex
new file mode 100644
index 000000000..dfa504579
--- /dev/null
+++ b/lib/service/metadata/comment.ex
@@ -0,0 +1,22 @@
+defimpl Mobilizon.Service.Metadata, for: Mobilizon.Events.Comment do
+  alias Phoenix.HTML
+  alias Phoenix.HTML.Tag
+  alias Mobilizon.Events.Comment
+
+  def build_tags(%Comment{} = comment) do
+    comment
+    |> do_build_tags()
+    |> Enum.map(&HTML.safe_to_string/1)
+    |> Enum.reduce("", fn tag, acc -> acc <> tag end)
+  end
+
+  defp do_build_tags(%Comment{} = comment) do
+    [
+      Tag.tag(:meta, property: "og:title", content: comment.actor.preferred_username),
+      Tag.tag(:meta, property: "og:url", content: comment.url),
+      Tag.tag(:meta, property: "og:description", content: comment.text),
+      Tag.tag(:meta, property: "og:type", content: "website"),
+      Tag.tag(:meta, property: "twitter:card", content: "summary")
+    ]
+  end
+end
diff --git a/lib/service/metadata/event.ex b/lib/service/metadata/event.ex
new file mode 100644
index 000000000..8a63d7b84
--- /dev/null
+++ b/lib/service/metadata/event.ex
@@ -0,0 +1,24 @@
+defimpl Mobilizon.Service.Metadata, for: Mobilizon.Events.Event do
+  alias Phoenix.HTML
+  alias Phoenix.HTML.Tag
+  alias Mobilizon.Events.Event
+
+  def build_tags(%Event{} = event) do
+    event
+    |> do_build_tags()
+    |> Enum.map(&HTML.safe_to_string/1)
+    |> Enum.reduce("", fn tag, acc -> acc <> tag end)
+  end
+
+  defp do_build_tags(%Event{} = event) do
+    [
+      Tag.tag(:meta, property: "og:title", content: event.title),
+      Tag.tag(:meta, property: "og:url", content: event.url),
+      Tag.tag(:meta, property: "og:description", content: event.description),
+      Tag.tag(:meta, property: "og:type", content: "website"),
+      Tag.tag(:meta, property: "og:image", content: event.thumbnail),
+      Tag.tag(:meta, property: "og:image", content: event.large_image),
+      Tag.tag(:meta, property: "twitter:card", content: "summary_large_image")
+    ]
+  end
+end