diff --git a/lib/archethic_web/controllers/api/web_hosting_controller.ex b/lib/archethic_web/controllers/api/web_hosting_controller.ex new file mode 100644 index 000000000..257b52a87 --- /dev/null +++ b/lib/archethic_web/controllers/api/web_hosting_controller.ex @@ -0,0 +1,174 @@ +defmodule ArchethicWeb.API.WebHostingController do + @moduledoc false + + use ArchethicWeb, :controller + + alias Archethic + + alias Archethic.TransactionChain.Transaction + alias Archethic.TransactionChain.TransactionData + + alias Archethic.Crypto + + use Pathex + + require Logger + + @spec web_hosting(Plug.Conn.t(), map) :: Plug.Conn.t() + def web_hosting(conn, %{"address" => address, "url_path" => url_path}) do + with {:ok, address} <- Base.decode16(address, case: :mixed), + true <- Crypto.valid_address?(address), + {:ok, %Transaction{address: last_address, data: %TransactionData{content: content}}} <- + Archethic.get_last_transaction(address), + {:ok, json_content} <- Jason.decode(content), + {:ok, file, mime_type} <- get_file(json_content, url_path), + {cached?, etag} <- get_cache(conn, last_address, url_path), + {:ok, file_content, encodage} <- get_file_content(file, cached?) do + conn = + conn + |> put_resp_content_type(mime_type, "utf-8") + |> put_resp_header("content-encoding", "gzip") + |> put_resp_header("cache-control", "public") + |> put_resp_header("etag", etag) + + if cached? do + send_resp(conn, 304, "") + else + case encodage do + "gzip" -> + send_resp(conn, 200, file_content) + + _ -> + send_resp(conn, 200, :zlib.gzip(file_content)) + end + end + else + # Base.decode16 || Crypto.valid_address + er when er in [:error, false] -> + send_resp(conn, 400, "Invalid address") + + # Jason.decode + {:error, %Jason.DecodeError{}} -> + send_resp(conn, 400, "Invalid transaction content") + + # Archethic.get_last_transaction + {:error, _} -> + send_resp(conn, 400, "Invalid address") + + {:file_not_found, url} -> + send_resp(conn, 404, "File #{url} does not exist") + + :encodage_error -> + send_resp(conn, 400, "Invalid file encodage") + + :file_error -> + send_resp(conn, 400, "Cannot find file content") + + _reason -> + send_resp(conn, 404, "Not Found") + end + end + + # API without path returns default index.html file + # or the only file if there is only one + @spec get_file(json_content :: map(), url_path :: list()) :: + {:ok, map(), binary()} | {:file_not_found, binary()} + defp get_file(json_content, url_path) do + {json_path, url} = + case Enum.count(url_path) do + 0 -> + file_name = get_single_file_name(json_content) + json_path = path(file_name) + {json_path, file_name} + + 1 -> + file_name = Enum.at(url_path, 0) + json_path = path(file_name) + {json_path, file_name} + + _ -> + json_path = get_json_path(url_path) + url = Path.join(url_path) + {json_path, url} + end + + case Pathex.view(json_content, json_path) do + {:ok, file} -> + {:ok, file, MIME.from_path(url)} + + :error -> + {:file_not_found, url} + end + end + + defp get_json_path(url_path) do + Enum.reduce(url_path, nil, fn value, acc -> + if acc == nil do + path(value) + else + acc ~> path(value) + end + end) + end + + defp get_single_file_name(json_content) do + keys = Map.keys(json_content) + + case Enum.count(keys) do + 1 -> + # Control if it is a file or a folder + file_name = Enum.at(keys, 0) + + if Map.get(json_content, file_name) |> Map.has_key?("content") do + file_name + else + "index.html" + end + + _ -> + "index.html" + end + end + + @spec get_cache(conn :: Plug.Conn.t(), last_address :: binary(), url_path :: list()) :: + {boolean(), binary()} + defp get_cache(conn, last_address, url_path) do + etag = + case Enum.empty?(url_path) do + true -> + Base.encode16(last_address, case: :lower) + + false -> + Base.encode16(last_address, case: :lower) <> Path.join(url_path) + end + + cached? = + case List.first(get_req_header(conn, "if-none-match")) do + got_etag when got_etag == etag -> + true + + _ -> + false + end + + {cached?, etag} + end + + # All file are encoded in base64 in JSON content + @spec get_file_content(file :: map(), cached? :: boolean()) :: + {:ok, binary(), binary() | nil} | :encodage_error | :file_error + defp get_file_content(_file, _cached? = true), do: {:ok, nil, nil} + + defp get_file_content(file = %{"content" => content}, _cached = false) do + try do + file_content = Base.url_decode64!(content, padding: false) + encodage = Map.get(file, "encodage") + {:ok, file_content, encodage} + rescue + _ -> + :encodage_error + end + end + + defp get_file_content(_, _), do: :file_error +end diff --git a/lib/archethic_web/router.ex b/lib/archethic_web/router.ex index 86f248915..94f5e736c 100644 --- a/lib/archethic_web/router.ex +++ b/lib/archethic_web/router.ex @@ -71,6 +71,8 @@ defmodule ArchethicWeb.Router do :last_transaction_content ) + get("/web_hosting/:address/*url_path", ArchethicWeb.API.WebHostingController, :web_hosting) + post("/origin_key", ArchethicWeb.API.OriginKeyController, :origin_key) post("/transaction", ArchethicWeb.API.TransactionController, :new) diff --git a/lib/archethic_web/views/explorer_view.ex b/lib/archethic_web/views/explorer_view.ex index 62b972dee..3e62ce9c7 100644 --- a/lib/archethic_web/views/explorer_view.ex +++ b/lib/archethic_web/views/explorer_view.ex @@ -210,6 +210,10 @@ defmodule ArchethicWeb.ExplorerView do Jason.Formatter.pretty_print_to_iodata(content) end + def format_transaction_content(:hosting, content) do + Jason.Formatter.pretty_print_to_iodata(content) + end + def format_transaction_content(_, content), do: content defp format_origin_shared_secrets_content(family, keys) do diff --git a/mix.exs b/mix.exs index 351d585e9..2d8503a3b 100644 --- a/mix.exs +++ b/mix.exs @@ -101,7 +101,8 @@ defmodule Archethic.MixProject do {:flow, "~> 1.0"}, {:broadway, "~> 1.0"}, {:knigge, "~> 1.4"}, - {:ex_json_schema, "~> 0.9.1", override: true} + {:ex_json_schema, "~> 0.9.1", override: true}, + {:pathex, "~> 2.0"} ] end diff --git a/mix.lock b/mix.lock index 6389714f6..7bd5304ca 100644 --- a/mix.lock +++ b/mix.lock @@ -48,6 +48,7 @@ "nimble_options": {:hex, :nimble_options, "0.3.7", "1e52dd7673d36138b1a5dede183b5d86dff175dc46d104a8e98e396b85b04670", [:mix], [], "hexpm", "2086907e6665c6b6579be54ef5001928df5231f355f71ed258f80a55e9f63633"}, "nimble_parsec": {:hex, :nimble_parsec, "1.2.0", "b44d75e2a6542dcb6acf5d71c32c74ca88960421b6874777f79153bbbbd7dccc", [:mix], [], "hexpm", "52b2871a7515a5ac49b00f214e4165a40724cf99798d8e4a65e4fd64ebd002c1"}, "observer_cli": {:hex, :observer_cli, "1.7.1", "c9ca1f623a3ef0158283a3c37cd7b7235bfe85927ad6e26396dd247e2057f5a1", [:mix, :rebar3], [{:recon, "~>2.5.1", [hex: :recon, repo: "hexpm", optional: false]}], "hexpm", "4ccafaaa2ce01b85ddd14591f4d5f6731b4e13b610a70fb841f0701178478280"}, + "pathex": {:hex, :pathex, "2.0.0", "b129359b22bf2005d5ed6bf41beb7aed64ca83d8510a0624a596a3fa23d34a1c", [:mix], [], "hexpm", "ffe038484ad8313fd91bec3ed51d0f821a4ba3eb6f2a1b14d9ab2685c5c3c876"}, "phoenix": {:hex, :phoenix, "1.5.13", "d4e0805ec0973bed80d67302631130fb47d75b1a0b7335a0b23c4432b6ce55ee", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_html, "~> 2.13 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:plug, "~> 1.10", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 1.0 or ~> 2.2", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.1.2 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "1a7c4f1900e6e60bb60ae6680e48418e3f7c360d58bcb9f812487b6d0d281a0f"}, "phoenix_html": {:hex, :phoenix_html, "2.14.3", "51f720d0d543e4e157ff06b65de38e13303d5778a7919bcc696599e5934271b8", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "efd697a7fff35a13eeeb6b43db884705cba353a1a41d127d118fda5f90c8e80f"}, "phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.4.0", "87990e68b60213d7487e65814046f9a2bed4a67886c943270125913499b3e5c3", [:mix], [{:ecto_psql_extras, "~> 0.4.1 or ~> 0.5", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:phoenix_html, "~> 2.14.1 or ~> 2.15", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.15.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.4.0 or ~> 0.5.0 or ~> 0.6.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "8d52149e58188e9e4497cc0d8900ab94d9b66f96998ec38c47c7a4f8f4f50e57"}, diff --git a/test/archethic_web/controllers/api/web_hosting_controller_test.exs b/test/archethic_web/controllers/api/web_hosting_controller_test.exs new file mode 100644 index 000000000..0db725271 --- /dev/null +++ b/test/archethic_web/controllers/api/web_hosting_controller_test.exs @@ -0,0 +1,303 @@ +defmodule ArchethicWeb.API.WebHostingControllerTest do + use ArchethicCase + use ArchethicWeb.ConnCase + + alias Archethic.P2P + alias Archethic.P2P.Node + + alias Archethic.Crypto + + alias Archethic.P2P.Message.GetLastTransaction + + alias Archethic.TransactionChain.Transaction + alias Archethic.TransactionChain.TransactionData + + import Mox + + setup do + P2P.add_and_connect_node(%Node{ + ip: {127, 0, 0, 1}, + port: 3000, + first_public_key: Crypto.last_node_public_key(), + last_public_key: Crypto.last_node_public_key(), + network_patch: "AAA", + geo_patch: "AAA", + available?: true, + authorized?: true, + authorization_date: DateTime.utc_now() + }) + + :ok + end + + describe "web_hosting/2" do + test "should return Invalid address", %{conn: conn} do + MockClient + |> stub(:send_message, fn _, %GetLastTransaction{}, _ -> + {:error, :transaction_not_exists} + end) + + conn1 = get(conn, "/api/web_hosting/AZERTY") + conn2 = get(conn, "/api/web_hosting/0123456789") + + conn3 = + get( + conn, + "/api/web_hosting/0000225496a380d5005cb68374e9b8b45d7e0f505a42f8cd61cbd43c3684c5cbacba" + ) + + assert "Invalid address" = response(conn1, 400) + assert "Invalid address" = response(conn2, 400) + assert "Invalid address" = response(conn3, 400) + end + + test "should return Invalid transaction content", %{conn: conn} do + MockClient + |> stub(:send_message, fn _, %GetLastTransaction{}, _ -> + {:ok, + %Transaction{ + address: + <<0, 0, 34, 84, 150, 163, 128, 213, 0, 92, 182, 131, 116, 233, 184, 180, 93, 126, 15, + 80, 90, 66, 248, 205, 97, 203, 212, 60, 54, 132, 197, 203, 172, 186>>, + data: %TransactionData{content: "invalid"} + }} + end) + + conn = + get( + conn, + "/api/web_hosting/0000225496a380d5005cb68374e9b8b45d7e0f505a42f8cd61cbd43c3684c5cbacba" + ) + + assert "Invalid transaction content" = response(conn, 400) + end + end + + describe "get_file/2" do + setup do + content = """ + { + "index.html":{ + "encodage":"base64", + "content":"PGgxPkFyY2hldGhpYzwvaDE-" + }, + "folder":{ + "hello_world.html":{ + "encodage":"base64", + "content":"PGgxPkhlbGxvIHdvcmxkICE8L2gxPg" + } + } + } + """ + + MockClient + |> stub(:send_message, fn _, %GetLastTransaction{}, _ -> + {:ok, + %Transaction{ + address: + <<0, 0, 34, 84, 150, 163, 128, 213, 0, 92, 182, 131, 116, 233, 184, 180, 93, 126, 15, + 80, 90, 66, 248, 205, 97, 203, 212, 60, 54, 132, 197, 203, 172, 186>>, + data: %TransactionData{content: content} + }} + end) + + :ok + end + + test "should return file does not exist", %{conn: conn} do + conn = + get( + conn, + "/api/web_hosting/0000225496a380d5005cb68374e9b8b45d7e0f505a42f8cd61cbd43c3684c5cbacba/file.html" + ) + + assert "File file.html does not exist" = response(conn, 404) + end + + test "should return default index.html file", %{conn: conn} do + conn = + get( + conn, + "/api/web_hosting/0000225496a380d5005cb68374e9b8b45d7e0f505a42f8cd61cbd43c3684c5cbacba" + ) + + assert "

Archethic

" = response(conn, 200) |> :zlib.gunzip() + end + + test "should return selected file", %{conn: conn} do + conn = + get( + conn, + "/api/web_hosting/0000225496a380d5005cb68374e9b8b45d7e0f505a42f8cd61cbd43c3684c5cbacba/folder/hello_world.html" + ) + + assert "

Hello world !

" = response(conn, 200) |> :zlib.gunzip() + end + end + + describe "get_file_content/2" do + setup do + content = """ + { + "error.html":{ + "encodage":"gzip", + "content":"4rdHFh%2BHYoS8oLdVvbUzEVqB8Lvm7kSPnuwF0AAABYQ%3D" + }, + "gzip.js":{ + "encodage":"base64", + "content":"PGgxPkhlbGxvIHdvcmxkICE8L2gxPg" + }, + "unsupported.xml":{ + "encodage":"unsupported", + "content":"PGgxPkhlbGxvIHdvcmxkICE8L2gxPg" + }, + "raw.html":{ + "content":"PGgxPkhlbGxvIHdvcmxkICE8L2gxPg" + }, + "no_content.html":{ + "unsupported":"unsupported" + }, + "image.png":{ + "content":"PGgxPkhlbGxvIHdvcmxkICE8L2gxPg" + } + } + """ + + MockClient + |> stub(:send_message, fn _, %GetLastTransaction{}, _ -> + {:ok, + %Transaction{ + address: + <<0, 0, 34, 84, 150, 163, 128, 213, 0, 92, 182, 131, 116, 233, 184, 180, 93, 126, 15, + 80, 90, 66, 248, 205, 97, 203, 212, 60, 54, 132, 197, 203, 172, 186>>, + data: %TransactionData{content: content} + }} + end) + + :ok + end + + test "should return Invalid file encodage", %{conn: conn} do + conn = + get( + conn, + "/api/web_hosting/0000225496a380d5005cb68374e9b8b45d7e0f505a42f8cd61cbd43c3684c5cbacba/error.html" + ) + + assert "Invalid file encodage" = response(conn, 400) + end + + test "should return Cannot find file content", %{conn: conn} do + conn = + get( + conn, + "/api/web_hosting/0000225496a380d5005cb68374e9b8b45d7e0f505a42f8cd61cbd43c3684c5cbacba/no_content.html" + ) + + assert "Cannot find file content" = response(conn, 400) + end + + test "should return decoded file content", %{conn: conn} do + conn = + get( + conn, + "/api/web_hosting/0000225496a380d5005cb68374e9b8b45d7e0f505a42f8cd61cbd43c3684c5cbacba/gzip.js" + ) + + assert "

Hello world !

" = response(conn, 200) |> :zlib.gunzip() + end + + test "should return raw file content", %{conn: conn} do + conn1 = + get( + conn, + "/api/web_hosting/0000225496a380d5005cb68374e9b8b45d7e0f505a42f8cd61cbd43c3684c5cbacba/raw.html" + ) + + conn2 = + get( + conn, + "/api/web_hosting/0000225496a380d5005cb68374e9b8b45d7e0f505a42f8cd61cbd43c3684c5cbacba/unsupported.xml" + ) + + assert "

Hello world !

" = response(conn1, 200) |> :zlib.gunzip() + assert "

Hello world !

" = response(conn2, 200) |> :zlib.gunzip() + end + + test "should return good file content-type", %{conn: conn} do + conn1 = + get( + conn, + "/api/web_hosting/0000225496a380d5005cb68374e9b8b45d7e0f505a42f8cd61cbd43c3684c5cbacba/raw.html" + ) + + conn2 = + get( + conn, + "/api/web_hosting/0000225496a380d5005cb68374e9b8b45d7e0f505a42f8cd61cbd43c3684c5cbacba/unsupported.xml" + ) + + conn3 = + get( + conn, + "/api/web_hosting/0000225496a380d5005cb68374e9b8b45d7e0f505a42f8cd61cbd43c3684c5cbacba/gzip.js" + ) + + conn4 = + get( + conn, + "/api/web_hosting/0000225496a380d5005cb68374e9b8b45d7e0f505a42f8cd61cbd43c3684c5cbacba/image.png" + ) + + assert ["text/html; charset=utf-8"] = get_resp_header(conn1, "content-type") + assert ["text/xml; charset=utf-8"] = get_resp_header(conn2, "content-type") + assert ["text/javascript; charset=utf-8"] = get_resp_header(conn3, "content-type") + assert ["image/png; charset=utf-8"] = get_resp_header(conn4, "content-type") + end + end + + describe "get_cache/3" do + test "should return 304 status if file is cached in browser", %{conn: conn} do + content = """ + { + "folder":{ + "hello_world.html":{ + "encodage":"base64", + "content":"PGgxPkhlbGxvIHdvcmxkICE8L2gxPg" + } + } + } + """ + + MockClient + |> stub(:send_message, fn _, %GetLastTransaction{}, _ -> + {:ok, + %Transaction{ + address: + <<0, 0, 34, 84, 150, 163, 128, 213, 0, 92, 182, 131, 116, 233, 184, 180, 93, 126, 15, + 80, 90, 66, 248, 205, 97, 203, 212, 60, 54, 132, 197, 203, 172, 186>>, + data: %TransactionData{content: content} + }} + end) + + conn1 = + get( + conn, + "/api/web_hosting/0000225496a380d5005cb68374e9b8b45d7e0f505a42f8cd61cbd43c3684c5cbacba/folder/hello_world.html" + ) + + etag = get_resp_header(conn1, "etag") |> Enum.at(0) + + assert "0000225496a380d5005cb68374e9b8b45d7e0f505a42f8cd61cbd43c3684c5cbacbafolder/hello_world.html" = + etag + + conn2 = + get( + conn |> put_req_header("if-none-match", etag), + "/api/web_hosting/0000225496a380d5005cb68374e9b8b45d7e0f505a42f8cd61cbd43c3684c5cbacba/folder/hello_world.html" + ) + + assert "" = response(conn2, 304) + end + end +end