diff --git a/README.md b/README.md index baaab326..5aeaa913 100755 --- a/README.md +++ b/README.md @@ -66,6 +66,10 @@ Here is our [GraphQL wiki](https://cambiatus.github.io/onboarding.md) page - EOS Blockchain main [documentation](https://developers.eos.io/welcome/latest/overview/index) page - Here is [our documentation](eos.md) on how we use EOS blockchain +**HTTP server** + +- [NGINX](https://nginx.org/en/docs/) main documentation page + ## Development Environment Setup To build and run this application locally follow the following steps! @@ -120,6 +124,47 @@ Now you can visit [`localhost:4000`](http://localhost:4000) from your browser. #Boom! Now you can hack away! +## NGINX setup + +We use NGINX to manage our server infrastructure. You can check out how to install and run it on their [official site](https://nginx.org/) + +We also use NGINX to redirect social media crawlers in order to dynamically serve rich links (or Open Graphs) in accordance to the [Open Graph Protocol](https://ogp.me/). To set this up on your NGINX follow these steps: + +**Step 0** + +Locate your NGINX configuration file (`nginx.conf`) and get ready to edit it + +**Step 1** + +Add the following lines right before any of the server blocks are declared: + +``` +map $http_user_agent $rich_link_prefix { + default 0; + ~*(facebookexternalhit|twitterbot|telegrambot|linkedinbot|slackbot|whatsapp) /api/rich_link; +} +``` + +This code is responsible for detecting a crawler from different social media websites based on the user agent issued on the http request. + +If a crawler is detected it saves the user agent to the `$rich_link_prefix` variable. + +**Step 2** + +Inside the server block referring to `server_name block staging.cambiatus.io *.staging.cambiatus.io;` under `location /` insert: + +``` +proxy_set_header Host $http_host; +proxy_set_header X-Real-IP $remote_addr; +if ($rich_link_prefix != 0) { + proxy_pass http://127.0.0.1:#{backend_listening_port}$rich_link_prefix$request_uri; +} +``` + +Note: Replace `#{backend_listening_port}` with the port to which the backend server is setup to listen. + +This code redirects the identified crawler to the correct rich link URL. Nut if a crawler was not detected the server runs normally. + ## Contributing When you are ready to make your first contribution please check out our [contribution guide](/.github/contributing.md), this will get your up to speed on where and how to start. diff --git a/lib/cambiatus_web/controllers/rich_link_controller.ex b/lib/cambiatus_web/controllers/rich_link_controller.ex new file mode 100644 index 00000000..f4b0e5e1 --- /dev/null +++ b/lib/cambiatus_web/controllers/rich_link_controller.ex @@ -0,0 +1,97 @@ +defmodule CambiatusWeb.RichLinkController do + @moduledoc """ + Get data and render html to be used for rich links (also known as Open Graphs). + These rich links show additional information about the website when shared on social media + and must be compliant with the [Open Grap Protocol](https://ogp.me/) + """ + + use CambiatusWeb, :controller + + alias CambiatusWeb.Resolvers.{Accounts, Commune, Shop} + alias Cambiatus.Repo + + def rich_link(conn, params) do + data = + with community_subdomain <- conn.host do + case Map.get(params, "page") do + ["shop", id] -> + product_rich_link(id, community_subdomain) + + ["profile", account] -> + user_rich_link(account, community_subdomain) + + _ -> + community_rich_link(community_subdomain) + end + end + + case data do + {:ok, data} -> + render(conn, "rich_link.html", %{data: data}) + + {:error, reason} -> + send_resp(conn, 404, reason) + end + end + + def product_rich_link(id, community_subdomain) do + get_image = fn product -> + with product = Repo.preload(product, :images), + [image | _] <- product.images do + image.uri + else + _ -> + "https://cambiatus-uploads.s3.amazonaws.com/cambiatus-uploads/b214c106482a46ad89f3272761d3f5b5" + end + end + + case Shop.get_product(nil, %{id: id}, nil) do + {:ok, product} -> + {:ok, + %{ + description: product.description, + title: product.title, + url: community_subdomain <> "/shop/#{product.id}", + image: get_image.(product), + locale: nil + }} + + {:error, reason} -> + {:error, reason} + end + end + + def user_rich_link(account, community_subdomain) do + case Accounts.get_user(nil, %{account: account}, nil) do + {:ok, user} -> + {:ok, + %{ + description: user.bio, + title: if(user.name, do: user.name, else: user.account), + url: community_subdomain <> "/profile/#{user.account}", + image: user.avatar, + locale: user.location + }} + + {:error, reason} -> + {:error, reason} + end + end + + def community_rich_link(community_subdomain) do + case Commune.find_community(%{}, %{subdomain: community_subdomain}, %{}) do + {:ok, community} -> + {:ok, + %{ + description: community.description, + title: community.name, + url: community_subdomain, + image: community.logo, + locale: nil + }} + + {:error, reason} -> + {:error, reason} + end + end +end diff --git a/lib/cambiatus_web/router.ex b/lib/cambiatus_web/router.ex index c4bff93c..1a14b84b 100755 --- a/lib/cambiatus_web/router.ex +++ b/lib/cambiatus_web/router.ex @@ -51,6 +51,7 @@ defmodule CambiatusWeb.Router do get("/chain/info", ChainController, :info) post("/invite", InviteController, :invite) get("/manifest", ManifestController, :manifest) + get("/rich_link/*page", RichLinkController, :rich_link) post("/paypal", PaypalController, :index) end diff --git a/lib/cambiatus_web/templates/layout/app.html.eex b/lib/cambiatus_web/templates/layout/app.html.eex new file mode 100644 index 00000000..05433985 --- /dev/null +++ b/lib/cambiatus_web/templates/layout/app.html.eex @@ -0,0 +1 @@ +<%= @inner_content %> diff --git a/lib/cambiatus_web/templates/rich_link/rich_link.html.eex b/lib/cambiatus_web/templates/rich_link/rich_link.html.eex new file mode 100644 index 00000000..e25fbbf4 --- /dev/null +++ b/lib/cambiatus_web/templates/rich_link/rich_link.html.eex @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + <%= @data.title %> + diff --git a/lib/cambiatus_web/views/rich_link_view.ex b/lib/cambiatus_web/views/rich_link_view.ex new file mode 100644 index 00000000..17c90fe2 --- /dev/null +++ b/lib/cambiatus_web/views/rich_link_view.ex @@ -0,0 +1,17 @@ +defmodule CambiatusWeb.RichLinkView do + use CambiatusWeb, :view + + require Earmark + require HtmlSanitizeEx + + def md_to_txt(markdown) do + with {:ok, string, _} <- Earmark.as_html(markdown, escape: false) do + string + |> HtmlSanitizeEx.strip_tags() + |> String.trim() + else + {:error, _} -> + "" + end + end +end diff --git a/mix.exs b/mix.exs index 5747d0fa..5a3e9326 100755 --- a/mix.exs +++ b/mix.exs @@ -49,6 +49,7 @@ defmodule Cambiatus.Mixfile do {:ex_phone_number, "~> 0.2"}, {:number, "~> 1.0"}, {:earmark, "~> 1.4"}, + {:html_sanitize_ex, "~> 1.4"}, # Email capabilities {:swoosh, "~> 1.0"}, diff --git a/test/cambiatus_web/controllers/rich_link_controller_test.exs b/test/cambiatus_web/controllers/rich_link_controller_test.exs new file mode 100644 index 00000000..c34a49e1 --- /dev/null +++ b/test/cambiatus_web/controllers/rich_link_controller_test.exs @@ -0,0 +1,179 @@ +defmodule CambiatusWeb.RichLinkControllerTest do + use Cambiatus.DataCase + use CambiatusWeb.ConnCase + + alias Cambiatus.Repo + + setup %{conn: conn} do + {:ok, conn: put_req_header(conn, "accept", "application/json")} + end + + describe "RichLink" do + test "generate rich link for community", + %{conn: conn} do + # Insert community and extract data for the rich link + community = + insert(:community) + |> Repo.preload(:subdomain) + + expected_data = %{ + description: md_to_txt(community.description), + title: community.name, + url: community.subdomain.name, + image: community.logo, + locale: nil + } + + # Submit GET request for a community rich link + conn = + %{conn | host: community.subdomain.name} + |> get("/api/rich_link") + + response = html_response(conn, 200) + + # Check if all the rich link fields are properly filled + Enum.each(expected_data, fn {k, v} -> + assert String.match?(response, ~r/meta property=\"og:#{k}\" content=\"#{v}/) + end) + end + + test "generate rich link for user", + %{conn: conn} do + # Insert user and extract data for the rich link + + user = insert(:user) + + community = + insert(:community) + |> Repo.preload(:subdomain) + + expected_data = %{ + description: md_to_txt(user.bio), + title: user.name, + url: community.subdomain.name <> "/profile/#{user.account}", + image: user.avatar, + locale: user.location + } + + # Submit GET request for a user rich link + conn = + %{conn | host: community.subdomain.name} + |> get("/api/rich_link/profile/#{user.account}") + + response = html_response(conn, 200) + + # Check if all the rich link fields are properly filled + Enum.each(expected_data, fn {k, v} -> + assert String.match?(response, ~r/meta property=\"og:#{k}\" content=\"#{v}/) + end) + end + + test "generate rich link for user without name", + %{conn: conn} do + # Insert user and extract data for the rich link + + user = insert(:user, name: nil) + + community = + insert(:community) + |> Repo.preload(:subdomain) + + expected_data = %{ + description: md_to_txt(user.bio), + title: user.account, + url: community.subdomain.name <> "/profile/#{user.account}", + image: user.avatar, + locale: user.location + } + + # Submit GET request for a user rich link + conn = + %{conn | host: community.subdomain.name} + |> get("/api/rich_link/profile/#{user.account}") + + response = html_response(conn, 200) + + # Check if all the rich link fields are properly filled + Enum.each(expected_data, fn {k, v} -> + assert String.match?(response, ~r/meta property=\"og:#{k}\" content=\"#{v}/) + end) + end + + test "generate rich link for product with image", + %{conn: conn} do + # Insert product and extract data for the rich link + product = + insert(:product) + |> Repo.preload(:images) + + [image | _] = product.images + + community = + insert(:community) + |> Repo.preload(:subdomain) + + expected_data = %{ + description: md_to_txt(product.description), + title: product.title, + url: community.subdomain.name <> "/shop/#{product.id}", + image: image.uri, + locale: nil + } + + # Submit GET request for a product rich link + conn = + %{conn | host: community.subdomain.name} + |> get("/api/rich_link/shop/#{product.id}") + + response = html_response(conn, 200) + + # Check if all the rich link fields are properly filled + Enum.each(expected_data, fn {k, v} -> + assert String.match?(response, ~r/meta property=\"og:#{k}\" content=\"#{v}/) + end) + end + end + + test "generate rich link for product without image", + %{conn: conn} do + # Insert product without images and extract data for the rich link + product = insert(:product, images: []) + + community = + insert(:community) + |> Repo.preload(:subdomain) + + expected_data = %{ + description: md_to_txt(product.description), + title: product.title, + url: community.subdomain.name <> "/shop/#{product.id}", + image: + "https://cambiatus-uploads.s3.amazonaws.com/cambiatus-uploads/b214c106482a46ad89f3272761d3f5b5", + locale: nil + } + + # Submit GET request for a product rich link + conn = + %{conn | host: community.subdomain.name} + |> get("/api/rich_link/shop/#{product.id}") + + response = html_response(conn, 200) + + # Check if all the rich link fields are properly filled + Enum.each(expected_data, fn {k, v} -> + assert String.match?(response, ~r/meta property=\"og:#{k}\" content=\"#{v}/) + end) + end + + defp md_to_txt(markdown) do + # Convert markdown to plain text + with {:ok, string, _} <- Earmark.as_html(markdown, escape: false) do + string + |> HtmlSanitizeEx.strip_tags() + |> String.trim() + else + {:error, _} -> + "" + end + end +end