File 4731-Implement-simple-command-line-serving-via-httpd.patch of Package erlang

From 76e11f0990b64b3bf0769b8ee9d903a1addf1901 Mon Sep 17 00:00:00 2001
From: Johannes Christ <jc@jchri.st>
Date: Thu, 25 May 2023 11:52:10 +0200
Subject: [PATCH] Implement simple command-line serving via httpd

Often times people want to quickly have a directory index of some folder
to either view directly or share on the local network. For instance, in
Python, `python3 -m http.server` implements a simple server
accomplishing exactly this.

`httpd` has the needed functionality to do this, but did not define a
command-line entrypoint. Users who wanted to quickly serve files with
Erlang needed hacks like shown here:
https://gist.github.com/willurd/5720255#erlang.

This commit adds support for the following forms of hosting a simple
directory listing with support for `index.html` and basic mimetypes,
with the following commands:

    erl -S httpd
    erl -S httpd serve
    erl -S httpd serve path/to/dir

The original suggestion cropped up here:
https://erlangforums.com/t/httpc-httpd-improvements/2622/8
---
 lib/inets/doc/src/http_server.xml             |  22 ++
 lib/inets/src/http_server/httpd.erl           | 115 +++++++++
 lib/inets/test/Makefile                       |   3 +-
 lib/inets/test/httpd_serve_SUITE.erl          | 230 ++++++++++++++++++
 .../test/httpd_serve_SUITE_data/aaa/info.txt  |   1 +
 .../test/httpd_serve_SUITE_data/index.html    |   1 +
 .../httpd_serve_SUITE_data/sample_file.html   |   1 +
 .../httpd_serve_SUITE_data/subdir/index.html  |   1 +
 8 files changed, 373 insertions(+), 1 deletion(-)
 create mode 100644 lib/inets/test/httpd_serve_SUITE.erl
 create mode 100644 lib/inets/test/httpd_serve_SUITE_data/aaa/info.txt
 create mode 100644 lib/inets/test/httpd_serve_SUITE_data/index.html
 create mode 100644 lib/inets/test/httpd_serve_SUITE_data/sample_file.html
 create mode 100644 lib/inets/test/httpd_serve_SUITE_data/subdir/index.html

diff --git a/lib/inets/doc/src/http_server.xml b/lib/inets/doc/src/http_server.xml
index bc7507f703..78d1ee641c 100644
--- a/lib/inets/doc/src/http_server.xml
+++ b/lib/inets/doc/src/http_server.xml
@@ -612,6 +612,28 @@ start() ->
 	received.</p>
     </section>
   </section>
+  <section>
+    <title>Serving files from the command line</title>
+    <p>httpd includes functionality to quickly serve files from the command
+      line. In its simplest form, <c>erl -S httpd</c> will serve files in
+      the local directory on localhost.</p>
+    <taglist>
+      <tag><c>--port</c></tag>
+      <item>Sets the port to bind on. Defaults to <c>8000</c>.</item>
+
+      <tag><c>--bind</c></tag>
+      <item>Sets the bind address to listen on. Defaults to <c>127.0.0.1</c>.</item>
+
+      <tag><i>DIRECTORY</i></tag>
+      <item>Sets the directory to serve data from. Defaults to the current directory.</item>
+    </taglist>
+    <p>For example, to serve files from directory <c>test_results</c> on port <c>4000</c>:</p>
+    <code>
+      erl -S httpd serve --port 4000 test_results
+    </code>
+    <p>For a full reference of all options, run
+      <c>erl -S httpd serve --help</c>.</p>
+  </section>
 </chapter>
 
 
diff --git a/lib/inets/src/http_server/httpd.erl b/lib/inets/src/http_server/httpd.erl
index 0c662522fa..97e78e10fd 100644
--- a/lib/inets/src/http_server/httpd.erl
+++ b/lib/inets/src/http_server/httpd.erl
@@ -43,6 +43,8 @@
          info/3,
          info/4
         ]).
+%% Command line interface
+-export([start/1, serve/1]).
 
 -deprecated({parse_query, 1,
             "use uri_string:dissect_query/1 instead"}).
@@ -392,6 +394,119 @@ service_info(Pid) ->
 	    {error, service_not_available} 
     end.
 
+%%%--------------------------------------------------------------
+%%% Command line interface
+%%%--------------------------------------------------------------------
+
+parse_ip_address(Input) ->
+    case inet:parse_address(Input) of
+        {ok, Address} -> Address;
+        {error, einval} -> error(badarg)
+    end.
+
+%% Try to locate good mime types to use for the server.
+%% If none were found on the host, uses a slim default.
+default_mime_types() ->
+    Locations = [
+        "/etc/mime.types"
+        % Note nginx installations also occasionally host a `mime.types` file,
+        % but this is usually in nginx's own configuration file format. Apache,
+        % on the other hand, uses the standard format and can be used.
+    ],
+    find_mime_types(Locations).
+
+find_mime_types([Path | Paths]) ->
+    case filelib:is_file(Path) of
+        true -> Path;
+        false -> find_mime_types(Paths)
+    end;
+
+find_mime_types([]) ->
+    [
+        {"html", "text/html"}, {"htm", "text/html"}, {"js", "text/javascript"},
+        {"css","text/css"}, {"gif", "image/gif"}, {"jpg", "image/jpeg"},
+        {"jpeg", "image/jpeg"}, {"png", "image/png"}
+    ].
+
+serve_cli() ->
+    #{
+      arguments => [
+        #{
+          name => directory,
+          type => string,
+          help => "Directory to serve data from.",
+          default => "."
+        },
+        #{
+          name => help,
+          type => boolean,
+          short => $h,
+          long => "-help",
+          help => "Show this description."
+        },
+        #{
+          name => port,
+          type => {integer, [{min, 0}, {max, 65535}]},
+          short => $p,
+          long => "-port",
+          default => 8000,
+          help => (
+            "Port to bind on. Use '0' for the OS to automatically assign "
+            "a port which can then be seen on server startup."
+          )
+        },
+        #{
+          name => address,
+          type => {custom, fun parse_ip_address/1},
+          short => $b,
+          long => "-bind",
+          default => {127, 0, 0, 1},
+          help => "IP address to listen on. Use 0.0.0.0 or :: for all interfaces."
+        }
+      ],
+      help => "Start a HTTP server serving files from DIRECTORY.",
+      handler => fun do_serve/1
+    }.
+
+start(Args) ->
+    %% `-S` without a function and without arguments
+    serve(Args).
+
+serve(Args) ->
+    argparse:run(Args, serve_cli(), #{progname => "erl -S httpd serve"}).
+
+do_serve(#{help := true}) ->
+    io:format("~ts", [argparse:help(serve_cli())]),
+    erlang:halt(0);
+do_serve(#{address := Address, port := Port, directory := Path}) ->
+    AbsPath = string:trim(filename:absname(Path), trailing, "/."),
+    inets:start(),
+    IpFamilyOpts = case Address of 
+        {_, _, _, _} -> [];
+        _ -> [{ipfamily, inet6}]
+    end,
+    {ok, Pid} = start_service(
+      [
+         {bind_address, Address},
+         {document_root, AbsPath},
+         {server_root, AbsPath},
+         {directory_index, ["index.html"]},
+         {port, Port},
+         {mime_type, "application/octet-stream"},
+         {mime_types, default_mime_types()},
+         {modules, [mod_alias, mod_dir, mod_get]}
+      ] ++ IpFamilyOpts
+    ),
+    % This is needed to support random port assignment (--port 0)
+    [{port, ActualPort}] = info(Pid, [port]),
+    io:fwrite("~nStarted HTTP server on http://~s:~w at ~s~n",
+              [inet:ntoa(Address), ActualPort, AbsPath]),
+    receive
+        {From, shutdown} ->
+            ok = stop_service(Pid),
+            From ! done
+    end.
+
 %%%--------------------------------------------------------------
 %%% Internal functions
 %%%--------------------------------------------------------------------
diff --git a/lib/inets/test/Makefile b/lib/inets/test/Makefile
index da0805bc4d..0f8ba516a0 100644
--- a/lib/inets/test/Makefile
+++ b/lib/inets/test/Makefile
@@ -129,6 +129,7 @@ MODULES =                 		\
 	httpd_bench_SUITE               \
 	http_test_lib    		\
 	httpd_basic_SUITE		\
+	httpd_serve_SUITE       	\
 	httpd_load        		\
 	httpd_time_test	  		\
 	httpd_1_1         		\
@@ -164,7 +165,7 @@ INETS_FILES = inets.config $(INETS_SPECS)
 
 
 INETS_DATADIRS = inets_SUITE_data inets_socketwrap_SUITE_data
-HTTPD_DATADIRS = httpd_test_data httpd_SUITE_data httpd_basic_SUITE_data httpd_bench_SUITE_data
+HTTPD_DATADIRS = httpd_test_data httpd_SUITE_data httpd_basic_SUITE_data httpd_bench_SUITE_data httpd_serve_SUITE_data
 
 HTTPC_DATADIRS = httpc_SUITE_data httpc_proxy_SUITE_data
 
diff --git a/lib/inets/test/httpd_serve_SUITE.erl b/lib/inets/test/httpd_serve_SUITE.erl
new file mode 100644
index 0000000000..c49391c35c
--- /dev/null
+++ b/lib/inets/test/httpd_serve_SUITE.erl
@@ -0,0 +1,230 @@
+%% Tests for the `erl -S httpd serve` functionality.
+-module(httpd_serve_SUITE).
+-export([suite/0, all/0, groups/0]).
+-export([
+    argless_start/1,
+    argless_serve/1,
+    simple_random_port_serve/1,
+    serve_on_all_interfaces_v4/1,
+    serve_on_localhost_v4/1,
+    serve_on_all_interfaces_v6/1,
+    serve_on_localhost_v6/1,
+    serve_custom_directory/1
+]).
+
+%% When starting up servers for tests, these variables define how long to
+%% wait for the server to report that it has started up, and after how
+%% many retries to quit waiting for its report altogether.
+-define(STARTUP_WAIT_NAPTIME_MS, 20).
+-define(STARTUP_WAIT_RETRIES, 100).
+
+%% Default assertions to run in all tests.
+-define(DEFAULT_ASSERTIONS, [directory_index, random_file]).
+
+suite() ->
+    [{ct_hooks, [ts_install_cth]},
+     {timetrap, {seconds, 30}}].
+
+all() ->
+    [{group, httpd_serve_on_default_port},
+     {group, httpd_serve_on_random_ports}].
+
+groups() ->
+    [{httpd_serve_on_default_port, [sequence], [
+        argless_serve,
+        argless_start]},
+     {httpd_serve_on_random_ports, [parallel], [
+        simple_random_port_serve,
+        serve_on_all_interfaces_v4,
+        serve_on_localhost_v4,
+        serve_on_all_interfaces_v6,
+        serve_on_localhost_v6,
+        serve_custom_directory
+      ]}].
+
+%%
+%% Test cases
+%%
+
+%% Fixed ports (must be run one at a time)
+
+argless_start(_Config) ->
+    ServerFun = fun () -> httpd:start([]) end,
+    verify_server(ServerFun).
+
+argless_serve(_Config) ->
+    ServerFun = fun () -> httpd:serve([]) end,
+    verify_server(ServerFun).
+
+%% Random ports (can run in parallel)
+
+simple_random_port_serve(Config) ->
+    verify_server(["--port", "0", suite_data(Config)]).
+
+serve_on_all_interfaces_v4(Config) ->
+    verify_server(["--port", "0", "--bind", "0.0.0.0", suite_data(Config)]).
+
+serve_on_localhost_v4(Config) ->
+    verify_server(["--port", "0", "--bind", "127.0.0.1", suite_data(Config)]).
+
+serve_on_all_interfaces_v6(Config) ->
+    verify_server(["--port", "0", "--bind", "::", suite_data(Config)]).
+
+serve_on_localhost_v6(Config) ->
+    verify_server(["--port", "0", "--bind", "::1", suite_data(Config)]).
+
+serve_custom_directory(Config) ->
+    SuiteData = suite_data(Config),
+    verify_server(["--port", "0", filename:join(SuiteData, "subdir")]).
+
+%%
+%% Assertion functions
+%%
+
+%% Assert that the server responds properly.
+run_server_assertions(Response) ->
+    run_server_assertions(Response, ?DEFAULT_ASSERTIONS).
+
+%% Assert that the server responds properly.
+run_server_assertions({ok, {Ip, Port, Path}}, Assertions) when is_integer(Port) ->
+    % From the `filelib:wildcard/1` docs:
+    % "Directory separators must always be written as /, even on Windows."
+    IpToRequest = case Ip of
+        {_, _, _, _} -> inet:ntoa(Ip);
+        {_, _, _, _, _, _, _, _} -> "[" ++ inet:ntoa(Ip) ++ "]"
+    end,
+
+    ct:log("Validating custom assertions"),
+    DirectoryUrl = "http://" ++ IpToRequest ++ ":" ++ integer_to_list(Port) ++ "/",
+    ServerInfo = #{
+        url => DirectoryUrl,
+        bind_ip => Ip,
+        path => Path
+    },
+    ok = verify_assertions(Assertions, ServerInfo),
+    ct:comment("Ran ~w assertion(s).", [length(Assertions)]).
+
+%%
+%% Assertion helper functions
+%%
+
+verify_200_at(Url) ->
+    HttpcOpts = [{socket_opts, [{ipfamily, inet6fb4}]}],
+    Request = {Url, []},
+    Response = httpc:request(get, Request, [{autoredirect, false}], HttpcOpts),
+    case Response of
+        {ok, {{_Version, 200, _}, _Headers, _Body}} ->
+            Response;
+        {ok, {{_Version, 301, _}, Headers, _Body}} ->
+            % To be resilient against the case where the server is not
+            % reachable under its `server_name`, for instance, in test
+            % containers or other hosts with unexpected networking setups,
+            % replace the suggested hostname with the hostname we came from.
+            {_, SuggestedTarget} = proplists:lookup("location", Headers),
+            #{path := SuggestedPath} = uri_string:parse(SuggestedTarget),
+            OurUri = uri_string:parse(Url),
+            DecomposedTarget = maps:put(path, SuggestedPath, OurUri),
+            RedirectTarget = uri_string:recompose(DecomposedTarget),
+            RedirectedRequest = {RedirectTarget, []},
+            RedirectedResponse = httpc:request(get, RedirectedRequest, [], HttpcOpts),
+            ct:log("Following redirect (rewritten from ~s to ~s)", [SuggestedTarget, RedirectTarget]),
+            {ok, {{_, 200, _}, _, _}} = RedirectedResponse;
+        {error, {failed_connect, [{to_address, {"::", _Port}},
+                                  {inet6, [inet6], eaddrnotavail},
+                                  {inet, [inet], nxdomain}]}} ->
+            % In this case we could bind on all IPv6 interfaces (::) just
+            % fine, but could not issue requests to it due to an OS error.
+            % Write it off as a networking misconfiguration and skip.
+            exit({skip, "Could not reach host on IPv6 address ::"})
+    end.
+
+verify_assertions([], _ServerInfo) ->
+    ok;
+
+verify_assertions([directory_index | Assertions], #{url := Url} = ServerInfo) ->
+    ct:log("Validating directory index at ~s", [Url]),
+    verify_200_at(Url),
+    ct:log("Directory index received with a 200"),
+    verify_assertions(Assertions, ServerInfo);
+
+verify_assertions([random_file | Assertions],  #{url := Url, path := Path} = ServerInfo) ->
+    Files = filelib:wildcard(Path ++ "/*"),
+    File = lists:nth(rand:uniform(length(Files)), Files),
+    Basename = filename:basename(File),
+    FileUrl = Url ++ Basename,
+    ct:log("Validating random file at ~s", [FileUrl]),
+    verify_200_at(FileUrl),
+    ct:log("File received with a 200"),
+    verify_assertions(Assertions, ServerInfo).
+
+%%
+%% Helper functions
+%%
+
+suite_data(Config) ->
+    proplists:get_value(data_dir, Config).
+
+verify_server(FunOrArgs) ->
+    TestFun = fun run_server_assertions/1,
+    with_server(FunOrArgs, TestFun).
+
+with_server(Args, TestFun) when is_list(Args) ->
+    ServerFun = fun () -> httpd:serve(Args) end,
+    run_with_server(ServerFun, TestFun);
+
+with_server(ServerFun, TestFun) when is_function(ServerFun) ->
+    run_with_server(ServerFun, TestFun).
+
+run_with_server(ServerFun, TestFun) ->
+    ct:log("Starting server"),
+    ct:capture_start(),
+    {Child, _Reference} = spawn_monitor(ServerFun),
+    StartupResult = wait_for_startup_line(?STARTUP_WAIT_RETRIES),
+    ct:capture_stop(),
+    {ok, Line} = StartupResult,
+    Parsed = parse_startup_line(Line),
+    ct:log("Running test function"),
+    Result = TestFun(Parsed),
+    ct:log("Test function finished, shutting down server"),
+    Child ! {self(), shutdown},
+    receive done -> ok after 5000 -> ct:fail("No server shutdown after 5s") end,
+    ct:log("Server stopped"),
+    Result.
+
+%% Wait for `ct:capture_get' to give us the output we're looking for.
+wait_for_startup_line(Tries) ->
+    wait_for_startup_line([], [], Tries).
+
+wait_for_startup_line([], [], 0) ->
+    {error, no_output_at_all};
+wait_for_startup_line([], Unexpected, 0) ->
+    {error, {no_startup_line, unexpected_output, Unexpected}};
+wait_for_startup_line([], Unexpected, Tries) when Tries > 0 ->
+    receive
+        {'DOWN', _Reference, process, _Child, Info} ->
+            case Info of
+                {{badmatch, {error, {listen, eaddrnotavail}}}, _} ->
+                    exit({skip, "Adress not available to listen"});
+                _ ->
+                    ct:fail("Child process has died: ~w", [Info])
+            end
+    after 
+        0 -> ok
+    end,
+    timer:sleep(?STARTUP_WAIT_NAPTIME_MS),
+    wait_for_startup_line(ct:capture_get(), Unexpected, Tries - 1);
+wait_for_startup_line(["\nStarted HTTP" ++ _Rest = Line | _Lines], _Unexpected, _Tries) ->
+    {ok, Line};
+wait_for_startup_line([Line | Lines], Unexpected, Tries) ->
+    wait_for_startup_line(Lines, [Line | Unexpected], Tries).
+
+%% Parse the given line into a tuple.
+%% Example line:
+%%   Started HTTP server on http://127.0.0.1:8000 at /path/to/lib/inets/make_test_dir/ct_logs/ct_run.test_server@zulu.2023-06-06_12.07.27\n"
+parse_startup_line(Line) ->
+    {match, [_, RawIp, RawPort, Path]} = re:run(
+        Line, "^\nStarted HTTP server on http://(.+):(\\d+) at (.*)\\n$", [{capture, all, list}]
+    ),
+    {ok, Ip} = inet:parse_address(RawIp),
+    Port = list_to_integer(RawPort),
+    {ok, {Ip, Port, Path}}.
diff --git a/lib/inets/test/httpd_serve_SUITE_data/aaa/info.txt b/lib/inets/test/httpd_serve_SUITE_data/aaa/info.txt
new file mode 100644
index 0000000000..890527d5ba
--- /dev/null
+++ b/lib/inets/test/httpd_serve_SUITE_data/aaa/info.txt
@@ -0,0 +1 @@
+This directory serves to test proper redirects for subdirectories.
diff --git a/lib/inets/test/httpd_serve_SUITE_data/index.html b/lib/inets/test/httpd_serve_SUITE_data/index.html
new file mode 100644
index 0000000000..0741e1d445
--- /dev/null
+++ b/lib/inets/test/httpd_serve_SUITE_data/index.html
@@ -0,0 +1 @@
+<h1>httpd_serve_SUITE_data/index.html</h1>
diff --git a/lib/inets/test/httpd_serve_SUITE_data/sample_file.html b/lib/inets/test/httpd_serve_SUITE_data/sample_file.html
new file mode 100644
index 0000000000..5bbbe438b3
--- /dev/null
+++ b/lib/inets/test/httpd_serve_SUITE_data/sample_file.html
@@ -0,0 +1 @@
+<h1>httpd_serve_SUITE_data/sample_file.html</h1>
diff --git a/lib/inets/test/httpd_serve_SUITE_data/subdir/index.html b/lib/inets/test/httpd_serve_SUITE_data/subdir/index.html
new file mode 100644
index 0000000000..6add0208af
--- /dev/null
+++ b/lib/inets/test/httpd_serve_SUITE_data/subdir/index.html
@@ -0,0 +1 @@
+<h1>httpd_serve_SUITE_data/subdir/index.html</h1>
-- 
2.35.3

openSUSE Build Service is sponsored by