From 8f79ce9c209cd96e6fc67e3b0ce686f2ac3f71b0 Mon Sep 17 00:00:00 2001
From: bchamagne <74045243+bchamagne@users.noreply.github.com>
Date: Wed, 30 Nov 2022 15:00:21 +0100
Subject: [PATCH] Supports directory listing for the AEWeb's explorer (#705)
* AEWeb directory listing if there is no index.html
* Put explorer_url in config because we need to link to it from other domains in AEWeb
---
config/dev.exs | 7 +
config/prod.exs | 13 +
config/test.exs | 1 +
.../controllers/aeweb_root_controller.ex | 13 +-
.../controllers/api/web_hosting_controller.ex | 258 +++++++++++++-----
.../templates/dir_listing/index.html.heex | 165 +++++++++++
lib/archethic_web/views/dir_listing_view.ex | 28 ++
.../api/web_hosting_controller_test.exs | 106 +++++++
8 files changed, 524 insertions(+), 67 deletions(-)
create mode 100644 lib/archethic_web/templates/dir_listing/index.html.heex
create mode 100644 lib/archethic_web/views/dir_listing_view.ex
diff --git a/config/dev.exs b/config/dev.exs
index 4b170dfe5..7bd5ce8f7 100755
--- a/config/dev.exs
+++ b/config/dev.exs
@@ -129,6 +129,13 @@ config :archethic, ArchethicWeb.FaucetController, enabled: true
# watchers to your application. For example, we use it
# with webpack to recompile .js and .css sources.
config :archethic, ArchethicWeb.Endpoint,
+ explorer_url:
+ URI.to_string(%URI{
+ scheme: "https",
+ host: System.get_env("ARCHETHIC_DOMAIN_NAME", "localhost"),
+ port: System.get_env("ARCHETHIC_HTTPS_PORT", "5000") |> String.to_integer(),
+ path: "/explorer"
+ }),
http: [port: System.get_env("ARCHETHIC_HTTP_PORT", "4000") |> String.to_integer()],
server: true,
debug_errors: true,
diff --git a/config/prod.exs b/config/prod.exs
index 9b13f4a7f..b4e79ca96 100755
--- a/config/prod.exs
+++ b/config/prod.exs
@@ -228,6 +228,19 @@ config :archethic, ArchethicWeb.FaucetController,
# which you should run after static files are built and
# before starting your production server.
config :archethic, ArchethicWeb.Endpoint,
+ explorer_url:
+ URI.to_string(%URI{
+ scheme: "https",
+ host:
+ case(System.get_env("ARCHETHIC_NETWORK_TYPE") == "testnet") do
+ true ->
+ System.get_env("ARCHETHIC_DOMAIN_NAME", "testnet.archethic.net")
+
+ false ->
+ System.get_env("ARCHETHIC_DOMAIN_NAME", "mainnet.archethic.net")
+ end,
+ path: "/explorer"
+ }),
http: [:inet6, port: System.get_env("ARCHETHIC_HTTP_PORT", "40000") |> String.to_integer()],
url: [host: nil, port: System.get_env("ARCHETHIC_HTTP_PORT", "40000") |> String.to_integer()],
cache_static_manifest: "priv/static/cache_manifest.json",
diff --git a/config/test.exs b/config/test.exs
index 981dc2819..9e643ee57 100755
--- a/config/test.exs
+++ b/config/test.exs
@@ -156,5 +156,6 @@ config :archethic, Archethic.Utils.DetectNodeResponsiveness, timeout: 1_000
# We don't run a server during test. If one is required,
# you can enable the server option below.
config :archethic, ArchethicWeb.Endpoint,
+ explorer_url: "",
http: [port: 4002],
server: false
diff --git a/lib/archethic_web/controllers/aeweb_root_controller.ex b/lib/archethic_web/controllers/aeweb_root_controller.ex
index db608fe8f..cfcce9578 100644
--- a/lib/archethic_web/controllers/aeweb_root_controller.ex
+++ b/lib/archethic_web/controllers/aeweb_root_controller.ex
@@ -12,14 +12,23 @@ defmodule ArchethicWeb.AEWebRootController do
{:ok, file_content, encodage, mime_type, cached?, etag} ->
WebHostingController.send_response(conn, file_content, encodage, mime_type, cached?, etag)
- {:error, :not_found} ->
+ {:error, {:is_a_directory, transaction}} ->
+ {:ok, listing_html, encodage, mime_type, cached?, etag} =
+ WebHostingController.dir_listing(conn.request_path, params, transaction, cache_headers)
+
+ WebHostingController.send_response(conn, listing_html, encodage, mime_type, cached?, etag)
+
+ {:error, :file_not_found} ->
# If file is not found, returning default file (url can be handled by index file)
case url_path do
[] ->
send_resp(conn, 404, "Not Found")
+ ["index.html"] ->
+ send_resp(conn, 400, "Not Found")
+
_path ->
- params = Map.put(params, "url_path", [])
+ params = Map.put(params, "url_path", ["index.html"])
index(conn, params)
end
diff --git a/lib/archethic_web/controllers/api/web_hosting_controller.ex b/lib/archethic_web/controllers/api/web_hosting_controller.ex
index e1cd4c9c3..ab1dc9427 100644
--- a/lib/archethic_web/controllers/api/web_hosting_controller.ex
+++ b/lib/archethic_web/controllers/api/web_hosting_controller.ex
@@ -6,6 +6,7 @@ defmodule ArchethicWeb.API.WebHostingController do
alias Archethic
alias Archethic.TransactionChain.Transaction
+ alias Archethic.TransactionChain.Transaction.ValidationStamp
alias Archethic.TransactionChain.TransactionData
alias Archethic.Crypto
@@ -15,6 +16,7 @@ defmodule ArchethicWeb.API.WebHostingController do
require Logger
def web_hosting(conn, params = %{"url_path" => []}) do
+ # /web_hosting/:addr redirects to /web_hosting/:addr/
if String.last(conn.request_path) != "/" do
redirect(conn, to: conn.request_path <> "/")
else
@@ -38,12 +40,18 @@ defmodule ArchethicWeb.API.WebHostingController do
{:error, :website_not_found} ->
send_resp(conn, 404, "Cannot find website content")
- {:error, :not_found} ->
+ {:error, :file_not_found} ->
send_resp(conn, 404, "Cannot find file content")
{:error, :invalid_encodage} ->
send_resp(conn, 400, "Invalid file encodage")
+ {:error, {:is_a_directory, transaction}} ->
+ {:ok, listing_html, encodage, mime_type, cached?, etag} =
+ dir_listing(conn.request_path, params, transaction, get_cache_headers(conn))
+
+ send_response(conn, listing_html, encodage, mime_type, cached?, etag)
+
{:error, _} ->
send_resp(conn, 404, "Not Found")
end
@@ -57,7 +65,8 @@ defmodule ArchethicWeb.API.WebHostingController do
cached? :: boolean(), etag :: binary()}
| {:error, :invalid_address}
| {:error, :invalid_content}
- | {:error, :not_found}
+ | {:error, :file_not_found}
+ | {:error, :is_a_directory}
| {:error, :invalid_encodage}
| {:error, any()}
def get_website(params = %{"address" => address}, cache_headers) do
@@ -65,38 +74,167 @@ defmodule ArchethicWeb.API.WebHostingController 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(cache_headers, last_address, url_path),
- {:ok, file_content, encodage} <- get_file_content(file, cached?, url_path) do
- {:ok, file_content, encodage, mime_type, cached?, etag}
+ {:ok,
+ transaction = %Transaction{
+ address: last_address,
+ data: %TransactionData{content: content}
+ }} <-
+ Archethic.get_last_transaction(address) do
+ with {:ok, json_content} <- Jason.decode(content),
+ {:ok, file, mime_type} <- get_file(json_content, url_path),
+ {cached?, etag} <- get_cache(cache_headers, last_address, url_path),
+ {:ok, file_content, encodage} <- get_file_content(file, cached?, url_path) do
+ {:ok, file_content, encodage, mime_type, cached?, etag}
+ else
+ :encodage_error ->
+ {:error, :invalid_encodage}
+
+ :file_error ->
+ {:error, :file_not_found}
+
+ {:error, %Jason.DecodeError{}} ->
+ {:error, :invalid_content}
+
+ {:error, :file_not_found} ->
+ {:error, :file_not_found}
+
+ {:error, :malformed} ->
+ # malformed file will return 404 as described in test "should return Cannot find file content"
+ {:error, :file_not_found}
+
+ {:error, :is_a_directory} ->
+ # return the transaction so the dir_listing function do not need to do the I/O
+ {:error, {:is_a_directory, transaction}}
+ end
else
er when er in [:error, false] ->
{:error, :invalid_address}
- {:error, %Jason.DecodeError{}} ->
- {:error, :invalid_content}
-
{:error, reason} when reason in [:transaction_not_exists, :transaction_invalid] ->
{:error, :website_not_found}
- {:file_not_found, _url} ->
- {:error, :not_found}
+ error ->
+ error
+ end
+ end
+
+ @spec dir_listing(
+ request_path :: String.t(),
+ params :: map(),
+ transaction :: Transaction.t(),
+ cached_headers :: list()
+ ) ::
+ {:ok, listing_html :: binary() | nil, encodage :: nil | binary(), mime_type :: binary(),
+ cached? :: boolean(), etag :: binary()}
+ def dir_listing(
+ request_path,
+ params,
+ %Transaction{
+ address: last_address,
+ data: %TransactionData{content: content},
+ validation_stamp: %ValidationStamp{timestamp: timestamp}
+ },
+ cache_headers
+ ) do
+ url_path = Map.get(params, "url_path", [])
+ mime_type = "text/html"
- :encodage_error ->
- {:error, :invalid_encodage}
+ case get_cache(cache_headers, last_address, url_path) do
+ {cached? = true, etag} ->
+ {:ok, nil, nil, mime_type, cached?, etag}
- :file_error ->
- {:error, :not_found}
+ {cached? = false, etag} ->
+ case Jason.decode(content) do
+ {:error, err = %Jason.DecodeError{}} ->
+ {:error, err}
- error ->
- error
+ {:ok, json_content} ->
+ assigns =
+ do_dir_listing(request_path, url_path, json_content, timestamp, last_address)
+
+ {:ok,
+ Phoenix.View.render_to_iodata(ArchethicWeb.DirListingView, "index.html", assigns),
+ nil, mime_type, cached?, etag}
+ end
end
end
+ defp do_dir_listing(request_path, url_path, json_content, timestamp, last_address) do
+ {json_content_subset, current_working_dir, parent_dir_href} =
+ case url_path do
+ [] ->
+ {json_content, "/", nil}
+
+ _ ->
+ {:ok, subset} = Pathex.view(json_content, get_json_path(url_path))
+
+ {
+ subset,
+ Path.join(["/" | url_path]),
+ %{href: request_path |> Path.join("..") |> Path.expand()}
+ }
+ end
+
+ json_content_subset
+ |> Enum.map(fn
+ {key, %{"address" => address}} ->
+ {:file, key, address}
+
+ {key, _} ->
+ {:dir, key}
+ end)
+ # sort directory last, then DESC order (it will be accumulated in reverse order below)
+ |> Enum.sort(fn
+ {:file, a, _}, {:file, b, _} ->
+ a > b
+
+ {:dir, a}, {:dir, b} ->
+ a > b
+
+ {:file, _, _}, {:dir, _} ->
+ true
+
+ {:dir, _}, {:file, _, _} ->
+ false
+ end)
+ |> Enum.reduce(%{dirs: [], files: []}, fn
+ {:file, name, addresses}, %{dirs: dirs_acc, files: files_acc} ->
+ item = %{
+ href: %{href: Path.join(request_path, name)},
+ last_modified: timestamp,
+ addresses: addresses,
+ name: name
+ }
+
+ %{dirs: dirs_acc, files: [item | files_acc]}
+
+ {:dir, name}, %{dirs: dirs_acc, files: files_acc} ->
+ # directories url end with a slash for relative url in website to work
+ item = %{
+ href: %{href: Path.join([request_path, name]) <> "/"},
+ last_modified: timestamp,
+ name: name
+ }
+
+ %{files: files_acc, dirs: [item | dirs_acc]}
+ end)
+ |> Enum.into(%{
+ cwd: current_working_dir,
+ parent_dir_href: parent_dir_href,
+ reference_transaction_href: %{
+ href:
+ Path.join([
+ Keyword.fetch!(
+ Application.get_env(:archethic, ArchethicWeb.Endpoint),
+ :explorer_url
+ ),
+ "transaction",
+ Base.encode16(last_address)
+ ])
+ }
+ })
+ end
+
@doc """
Return the list of headers for caching
"""
@@ -145,33 +283,43 @@ defmodule ArchethicWeb.API.WebHostingController do
end
end
- # API without path returns default index.html file
- # or the only file if there is only one
- 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}
+ defp get_file(json_content, path), do: get_file(json_content, path, nil)
- _ ->
- json_path = get_json_path(url_path)
- url = Path.join(url_path)
- {json_path, url}
- end
+ # case when we're parsing a reference tx
+ defp get_file(file = %{"address" => _}, [], previous_path_item) do
+ {:ok, file, MIME.from_path(previous_path_item)}
+ end
- case Pathex.view(json_content, json_path) do
- {:ok, file} ->
- {:ok, file, MIME.from_path(url)}
+ # case when we're parsing a storage tx
+ defp get_file(file, [], previous_path_item) when is_binary(file) do
+ {:ok, file, MIME.from_path(previous_path_item)}
+ end
- :error ->
- {:file_not_found, url}
+ # case when we're on a directory
+ defp get_file(json_content, [], _previous_path_item) when is_map(json_content) do
+ case Map.get(json_content, "index.html") do
+ nil ->
+ # make sure it is a directory instead of a malformed file
+ if Enum.all?(Map.values(json_content), &is_map/1) do
+ {:error, :is_a_directory}
+ else
+ {:error, :malformed}
+ end
+
+ file ->
+ {:ok, file, "text/html"}
+ end
+ end
+
+ # recurse until we are on the end of path
+ defp get_file(json_content, [path_item | rest], _previous_path_item) do
+ case Map.get(json_content, path_item) do
+ nil ->
+ #
+ {:error, :file_not_found}
+
+ json_content_subset ->
+ get_file(json_content_subset, rest, path_item)
end
end
@@ -185,26 +333,6 @@ defmodule ArchethicWeb.API.WebHostingController do
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)
- file_content = Map.get(json_content, file_name)
-
- if !is_map(file_content) or Map.has_key?(file_content, "address") do
- file_name
- else
- "index.html"
- end
-
- _ ->
- "index.html"
- end
- end
-
defp get_cache(cache_headers, last_address, url_path) do
etag =
case Enum.empty?(url_path) do
diff --git a/lib/archethic_web/templates/dir_listing/index.html.heex b/lib/archethic_web/templates/dir_listing/index.html.heex
new file mode 100644
index 000000000..d588d69bc
--- /dev/null
+++ b/lib/archethic_web/templates/dir_listing/index.html.heex
@@ -0,0 +1,165 @@
+
+
+
+ Index of <%=@cwd %>
+
+
+
+
+
+
+ <%= if @parent_dir_href do %>
+ Up to higher level directory
+ <% else %>
+
+ <% end %>
+
+
+
+
+ Name |
+ Transactions |
+ Last Modified |
+
+
+
+ <%= for dir <- @dirs do %>
+
+
+
+ |
+ |
+ <%= format_date(dir.last_modified) %> |
+
+ <% end %>
+ <%= for file <- @files do %>
+
+
+
+ |
+
+ <%= for address <- prepare_addresses(file.addresses) do %>
+ <%= address.text %>
+ <% end %>
+ |
+ <%= format_date(file.last_modified) %> |
+
+ <% end %>
+
+
+
+
+
+
+
+
diff --git a/lib/archethic_web/views/dir_listing_view.ex b/lib/archethic_web/views/dir_listing_view.ex
new file mode 100644
index 000000000..749fd5736
--- /dev/null
+++ b/lib/archethic_web/views/dir_listing_view.ex
@@ -0,0 +1,28 @@
+defmodule ArchethicWeb.DirListingView do
+ @moduledoc false
+ use ArchethicWeb, :view
+
+ @spec prepare_addresses(list(String.t())) :: list(map())
+ def prepare_addresses(addresses) do
+ explorer_url =
+ Keyword.fetch!(
+ Application.get_env(:archethic, ArchethicWeb.Endpoint),
+ :explorer_url
+ )
+
+ addresses
+ |> Enum.map(fn address ->
+ %{
+ href: %{
+ href: Path.join([explorer_url, "transaction", address])
+ },
+ text: shorten_address(address)
+ }
+ end)
+ end
+
+ @spec shorten_address(String.t()) :: String.t()
+ def shorten_address(address) do
+ String.slice(address, 4..7)
+ end
+end
diff --git a/test/archethic_web/controllers/api/web_hosting_controller_test.exs b/test/archethic_web/controllers/api/web_hosting_controller_test.exs
index 7946e5255..930209dee 100644
--- a/test/archethic_web/controllers/api/web_hosting_controller_test.exs
+++ b/test/archethic_web/controllers/api/web_hosting_controller_test.exs
@@ -12,6 +12,7 @@ defmodule ArchethicWeb.API.WebHostingControllerTest do
alias Archethic.P2P.Message.LastTransactionAddress
alias Archethic.TransactionChain.Transaction
+ alias Archethic.TransactionChain.Transaction.ValidationStamp
alias Archethic.TransactionChain.TransactionData
import Mox
@@ -368,6 +369,111 @@ defmodule ArchethicWeb.API.WebHostingControllerTest do
end
end
+ describe "should return a directory listing if there is no index.html file and more than 1 file" do
+ setup do
+ content = """
+ {
+ "dir1": {
+ "file10.txt":{
+ "encodage":"gzip",
+ "address":[
+ "000071fbc2205f3eba39d310baf15bd89a019b0929be76b7864852cb68c9cd6502de"
+ ]
+ },
+ "file11.txt":{
+ "encodage":"gzip",
+ "address":[
+ "000071fbc2205f3eba39d310baf15bd89a019b0929be76b7864852cb68c9cd6502de"
+ ]
+ }
+ },
+ "dir2": {
+ "hello.txt":{
+ "encodage":"gzip",
+ "address":[
+ "000071fbc2205f3eba39d310baf15bd89a019b0929be76b7864852cb68c9cd6502de"
+ ]
+ }
+ },
+ "dir3": {
+ "index.html":{
+ "encodage":"gzip",
+ "address":[
+ "000071fbc2205f3eba39d310baf15bd89a019b0929be76b7864852cb68c9cd6502de"
+ ]
+ }
+ },
+ "file1.txt":{
+ "encodage":"gzip",
+ "address":[
+ "000071fbc2205f3eba39d310baf15bd89a019b0929be76b7864852cb68c9cd6502de"
+ ]
+ },
+ "file2.txt":{
+ "encodage":"gzip",
+ "address":[
+ "000071fbc2205f3eba39d310baf15bd89a019b0929be76b7864852cb68c9cd6502de"
+ ]
+ },
+ "file3.txt":{
+ "encodage":"gzip",
+ "address":[
+ "000071fbc2205f3eba39d310baf15bd89a019b0929be76b7864852cb68c9cd6502de"
+ ]
+ }
+ }
+ """
+
+ MockClient
+ |> stub(:send_message, fn
+ _, %GetLastTransactionAddress{address: address}, _ ->
+ {:ok, %LastTransactionAddress{address: address}}
+
+ _, %GetTransaction{address: address}, _ ->
+ {:ok,
+ %Transaction{
+ address: address,
+ data: %TransactionData{content: content},
+ validation_stamp: %ValidationStamp{
+ timestamp: DateTime.utc_now()
+ }
+ }}
+ end)
+
+ :ok
+ end
+
+ test "should return a directory listing", %{conn: conn} do
+ # directory listing at root
+ conn1 =
+ get(
+ conn,
+ "/api/web_hosting/0000225496a380d5005cb68374e9b8b45d7e0f505a42f8cd61cbd43c3684c5cbacba/"
+ )
+
+ # directory listing in a sub folder with trailing /
+ conn2 =
+ get(
+ conn,
+ "/api/web_hosting/0000225496a380d5005cb68374e9b8b45d7e0f505a42f8cd61cbd43c3684c5cbacba/dir1/"
+ )
+
+ # directory listing in a sub folder w/o trailing /
+ conn3 =
+ get(
+ conn,
+ "/api/web_hosting/0000225496a380d5005cb68374e9b8b45d7e0f505a42f8cd61cbd43c3684c5cbacba/dir1"
+ )
+
+ html1 = response(conn1, 200)
+ html2 = response(conn2, 200)
+ html3 = response(conn3, 200)
+ assert String.contains?(html1, "Index of")
+ assert String.contains?(html2, "Index of")
+ assert String.contains?(html3, "Index of")
+ end
+ end
+
describe "get_file_content/3 with address_content" do
setup do
content = """