Compare commits
3 Commits
3a8c2cf4b0
...
925936c2f9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
925936c2f9 | ||
|
|
649526a17c | ||
|
|
f59ca64b1e |
228
README.md
228
README.md
@ -6,7 +6,7 @@ Currently there is only one thing, which is the Gajumaru HTTP Daemon.
|
|||||||
|
|
||||||
Last updated: September 23, 2025 (PRH).
|
Last updated: September 23, 2025 (PRH).
|
||||||
|
|
||||||
### Install Erlang and zx/zomp
|
### Prereq: Install Erlang and zx/zomp
|
||||||
|
|
||||||
Source: [*Building Erlang 26.2.5 on Ubuntu 24.04*](https://zxq9.com/archives/2905)
|
Source: [*Building Erlang 26.2.5 on Ubuntu 24.04*](https://zxq9.com/archives/2905)
|
||||||
|
|
||||||
@ -65,6 +65,15 @@ Adapt this to your Linux distribution.
|
|||||||
zx run erltris
|
zx run erltris
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Running the server
|
||||||
|
|
||||||
|
```
|
||||||
|
zxh runlocal
|
||||||
|
```
|
||||||
|
|
||||||
|
Then navigate to <http://localhost:8000/> in your browser.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
@ -805,3 +814,220 @@ index 0000000..27d24d2
|
|||||||
+-type body_part() :: {Field :: binary(), Data :: binary()}
|
+-type body_part() :: {Field :: binary(), Data :: binary()}
|
||||||
+ | {Field :: binary(), Name :: binary(), Data :: binary()}.
|
+ | {Field :: binary(), Name :: binary(), Data :: binary()}.
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Making an index.html
|
||||||
|
|
||||||
|
```diff
|
||||||
|
diff --git a/gex_httpd/priv/index.html b/gex_httpd/priv/index.html
|
||||||
|
new file mode 100644
|
||||||
|
index 0000000..2ba545c
|
||||||
|
--- /dev/null
|
||||||
|
+++ b/gex_httpd/priv/index.html
|
||||||
|
@@ -0,0 +1,11 @@
|
||||||
|
+<!DOCTYPE html>
|
||||||
|
+<html lang="en">
|
||||||
|
+<head>
|
||||||
|
+ <meta charset="UTF-8">
|
||||||
|
+ <title>Hello, world!</title>
|
||||||
|
+</head>
|
||||||
|
+
|
||||||
|
+<body>
|
||||||
|
+ <h1>Hello, world!</h1>
|
||||||
|
+</body>
|
||||||
|
+</html>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Serving the index.html page
|
||||||
|
|
||||||
|
- Big picture steps:
|
||||||
|
- have `gh_client.erl` parse whatever it gets on the socket into a `qhl:request()`
|
||||||
|
- write a function that takes in the `qhl:request()` and returns a `qhl:response()`
|
||||||
|
- write a function that takes the `qhl:response()` and render it into binary
|
||||||
|
- send that binary back over the socket
|
||||||
|
|
||||||
|
- [QHL reference](https://git.qpq.swiss/QPQ-AG/QHL/src/commit/7f77f9e3b19f58006df88a2a601e85835d300c37/src/qhl.erl)
|
||||||
|
|
||||||
|
```diff
|
||||||
|
diff --git a/gex_httpd/priv/404.html b/gex_httpd/priv/404.html
|
||||||
|
new file mode 100644
|
||||||
|
index 0000000..bfb09f3
|
||||||
|
--- /dev/null
|
||||||
|
+++ b/gex_httpd/priv/404.html
|
||||||
|
@@ -0,0 +1,11 @@
|
||||||
|
+<!DOCTYPE html>
|
||||||
|
+<html lang="en">
|
||||||
|
+<head>
|
||||||
|
+ <meta charset="UTF-8">
|
||||||
|
+ <title>QHL: 404</title>
|
||||||
|
+</head>
|
||||||
|
+
|
||||||
|
+<body>
|
||||||
|
+ <h1>404 Not Found</h1>
|
||||||
|
+</body>
|
||||||
|
+</html>
|
||||||
|
diff --git a/gex_httpd/priv/500.html b/gex_httpd/priv/500.html
|
||||||
|
new file mode 100644
|
||||||
|
index 0000000..19d2057
|
||||||
|
--- /dev/null
|
||||||
|
+++ b/gex_httpd/priv/500.html
|
||||||
|
@@ -0,0 +1,11 @@
|
||||||
|
+<!DOCTYPE html>
|
||||||
|
+<html lang="en">
|
||||||
|
+<head>
|
||||||
|
+ <meta charset="UTF-8">
|
||||||
|
+ <title>QHL: 500</title>
|
||||||
|
+</head>
|
||||||
|
+
|
||||||
|
+<body>
|
||||||
|
+ <h1>500 Internal Server Error</h1>
|
||||||
|
+</body>
|
||||||
|
+</html>
|
||||||
|
diff --git a/gex_httpd/src/gh_client.erl b/gex_httpd/src/gh_client.erl
|
||||||
|
index 44d206d..3abd46f 100644
|
||||||
|
--- a/gex_httpd/src/gh_client.erl
|
||||||
|
+++ b/gex_httpd/src/gh_client.erl
|
||||||
|
@@ -24,11 +24,14 @@
|
||||||
|
-export([system_continue/3, system_terminate/4,
|
||||||
|
system_get_state/1, system_replace_state/2]).
|
||||||
|
|
||||||
|
+-include("http.hrl").
|
||||||
|
+
|
||||||
|
|
||||||
|
%%% Type and Record Definitions
|
||||||
|
|
||||||
|
|
||||||
|
--record(s, {socket = none :: none | gen_tcp:socket()}).
|
||||||
|
+-record(s, {socket = none :: none | gen_tcp:socket(),
|
||||||
|
+ received = none :: none | binary()}).
|
||||||
|
|
||||||
|
|
||||||
|
%% An alias for the state record above. Aliasing state can smooth out annoyances
|
||||||
|
@@ -124,13 +127,38 @@ listen(Parent, Debug, ListenSocket) ->
|
||||||
|
%% The service loop itself. This is the service state. The process blocks on receive
|
||||||
|
%% of Erlang messages, TCP segments being received themselves as Erlang messages.
|
||||||
|
|
||||||
|
-loop(Parent, Debug, State = #s{socket = Socket}) ->
|
||||||
|
+loop(Parent, Debug, State = #s{socket = Socket, received = Received}) ->
|
||||||
|
ok = inet:setopts(Socket, [{active, once}]),
|
||||||
|
receive
|
||||||
|
{tcp, Socket, Message} ->
|
||||||
|
ok = io:format("~p received: ~tp~n", [self(), Message]),
|
||||||
|
- ok = gh_client_man:echo(Message),
|
||||||
|
- loop(Parent, Debug, State);
|
||||||
|
+ %% Received exists because web browsers usually use the same
|
||||||
|
+ %% acceptor socket for sequential requests
|
||||||
|
+ %%
|
||||||
|
+ %% QHL parses a request off the socket, and consumes all the data
|
||||||
|
+ %% pertinent to said task. Any additional data it finds on the
|
||||||
|
+ %% socket it hands back to us.
|
||||||
|
+ %%
|
||||||
|
+ %% That additional data, as I said, is usually the next request.
|
||||||
|
+ %%
|
||||||
|
+ %% We store that in our process state in the received=Received field
|
||||||
|
+ Message2 =
|
||||||
|
+ case Received of
|
||||||
|
+ none -> Message;
|
||||||
|
+ _ -> <<Received/binary, Message/binary>>
|
||||||
|
+ end,
|
||||||
|
+ %% beware: wrong typespec in QHL 0.1.0
|
||||||
|
+ %% see: https://git.qpq.swiss/QPQ-AG/QHL/pulls/1
|
||||||
|
+ case qhl:parse(Socket, Message2) of
|
||||||
|
+ {ok, Request, NewReceived} ->
|
||||||
|
+ ok = handle_request(Socket, Request),
|
||||||
|
+ NewState = State#s{received = NewReceived},
|
||||||
|
+ loop(Parent, Debug, NewState);
|
||||||
|
+ {error, Reason} ->
|
||||||
|
+ io:format("~p error: ~tp~n", [self(), Reason]),
|
||||||
|
+ ok = http_err(Socket, 500),
|
||||||
|
+ exit(normal)
|
||||||
|
+ end;
|
||||||
|
{tcp_closed, Socket} ->
|
||||||
|
ok = io:format("~p Socket closed, retiring.~n", [self()]),
|
||||||
|
exit(normal);
|
||||||
|
@@ -190,3 +218,85 @@ system_get_state(State) -> {ok, State}.
|
||||||
|
|
||||||
|
system_replace_state(StateFun, State) ->
|
||||||
|
{ok, StateFun(State), State}.
|
||||||
|
+
|
||||||
|
+
|
||||||
|
+%%%-------------------------------------------
|
||||||
|
+%%% http request handling
|
||||||
|
+%%%-------------------------------------------
|
||||||
|
+
|
||||||
|
+-spec handle_request(Socket, Request) -> ok
|
||||||
|
+ when Socket :: gen_tcp:socket(),
|
||||||
|
+ Request :: #request{}.
|
||||||
|
+
|
||||||
|
+%% ref: https://git.qpq.swiss/QPQ-AG/QHL/src/commit/7f77f9e3b19f58006df88a2a601e85835d300c37/include/http.hrl
|
||||||
|
+
|
||||||
|
+handle_request(Socket, #request{method = get, path = <<"/">>}) ->
|
||||||
|
+ IndexHtmlPath = filename:join([zx:get_home(), "priv", "index.html"]),
|
||||||
|
+ case file:read_file(IndexHtmlPath) of
|
||||||
|
+ {ok, ResponseBody} ->
|
||||||
|
+ %% see https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Messages#http_responses
|
||||||
|
+ Headers = [{"content-type", "text/html"}],
|
||||||
|
+ Response = #response{headers = Headers,
|
||||||
|
+ body = ResponseBody},
|
||||||
|
+ respond(Socket, Response);
|
||||||
|
+ Error ->
|
||||||
|
+ io:format("~p error: ~p~n", [self(), Error]),
|
||||||
|
+ http_err(Socket, 500)
|
||||||
|
+ end;
|
||||||
|
+handle_request(Socket, _) ->
|
||||||
|
+ http_err(Socket, 404).
|
||||||
|
+
|
||||||
|
+
|
||||||
|
+http_err(Socket, 404) ->
|
||||||
|
+ HtmlPath = filename:join([zx:get_home(), "priv", "404.html"]),
|
||||||
|
+ {ok, ResponseBody} = file:read_file(HtmlPath),
|
||||||
|
+ Headers = [{"content-type", "text/html"}],
|
||||||
|
+ Response = #response{headers = Headers,
|
||||||
|
+ code = 404,
|
||||||
|
+ body = ResponseBody},
|
||||||
|
+ respond(Socket, Response);
|
||||||
|
+% default error is 500
|
||||||
|
+http_err(Socket, _) ->
|
||||||
|
+ HtmlPath = filename:join([zx:get_home(), "priv", "500.html"]),
|
||||||
|
+ {ok, ResponseBody} = file:read_file(HtmlPath),
|
||||||
|
+ Headers = [{"content-type", "text/html"}],
|
||||||
|
+ Response = #response{headers = Headers,
|
||||||
|
+ code = 500,
|
||||||
|
+ body = ResponseBody},
|
||||||
|
+ respond(Socket, Response).
|
||||||
|
+
|
||||||
|
+
|
||||||
|
+respond(Socket, R = #response{code = Code, headers = Headers, body = Body}) ->
|
||||||
|
+ Slogan = slogan(Code),
|
||||||
|
+ ContentLength = byte_size(Body),
|
||||||
|
+ DefaultHeaders = [{"date", qhl:ridiculous_web_date()},
|
||||||
|
+ {"content-length", integer_to_list(ContentLength)}],
|
||||||
|
+ Headers2 = merge_headers(DefaultHeaders, Headers),
|
||||||
|
+ really_respond(Socket, R#response{slogan = Slogan,
|
||||||
|
+ headers = Headers2}).
|
||||||
|
+
|
||||||
|
+
|
||||||
|
+really_respond(Socket, #response{code = Code, slogan = Slogan, headers = Headers, body = Body}) ->
|
||||||
|
+ Response =
|
||||||
|
+ ["HTTP/1.1 ", integer_to_list(Code), " ", Slogan, "\r\n",
|
||||||
|
+ render_headers(Headers), "\r\n",
|
||||||
|
+ Body],
|
||||||
|
+ gen_tcp:send(Socket, Response).
|
||||||
|
+
|
||||||
|
+
|
||||||
|
+merge_headers(Defaults, Overwrites) ->
|
||||||
|
+ DefaultsMap = proplists:to_map(Defaults),
|
||||||
|
+ OverwritesMap = proplists:to_map(Overwrites),
|
||||||
|
+ FinalMap = maps:merge(DefaultsMap, OverwritesMap),
|
||||||
|
+ proplists:from_map(FinalMap).
|
||||||
|
+
|
||||||
|
+render_headers([{K, V} | Rest]) ->
|
||||||
|
+ [K, ": ", V, "\r\n",
|
||||||
|
+ render_headers(Rest)];
|
||||||
|
+render_headers([]) ->
|
||||||
|
+ [].
|
||||||
|
+
|
||||||
|
+
|
||||||
|
+slogan(200) -> "OK";
|
||||||
|
+slogan(404) -> "Not Found";
|
||||||
|
+slogan(500) -> "Internal Server Error".
|
||||||
|
```
|
||||||
|
|||||||
BIN
etc/hello-world.png
Normal file
BIN
etc/hello-world.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
11
gex_httpd/priv/404.html
Normal file
11
gex_httpd/priv/404.html
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>QHL: 404</title>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<h1>404 Not Found</h1>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
11
gex_httpd/priv/500.html
Normal file
11
gex_httpd/priv/500.html
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>QHL: 500</title>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<h1>500 Internal Server Error</h1>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
11
gex_httpd/priv/index.html
Normal file
11
gex_httpd/priv/index.html
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Hello, world!</title>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<h1>Hello, world!</h1>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@ -24,11 +24,14 @@
|
|||||||
-export([system_continue/3, system_terminate/4,
|
-export([system_continue/3, system_terminate/4,
|
||||||
system_get_state/1, system_replace_state/2]).
|
system_get_state/1, system_replace_state/2]).
|
||||||
|
|
||||||
|
-include("http.hrl").
|
||||||
|
|
||||||
|
|
||||||
%%% Type and Record Definitions
|
%%% Type and Record Definitions
|
||||||
|
|
||||||
|
|
||||||
-record(s, {socket = none :: none | gen_tcp:socket()}).
|
-record(s, {socket = none :: none | gen_tcp:socket(),
|
||||||
|
received = none :: none | binary()}).
|
||||||
|
|
||||||
|
|
||||||
%% An alias for the state record above. Aliasing state can smooth out annoyances
|
%% An alias for the state record above. Aliasing state can smooth out annoyances
|
||||||
@ -124,13 +127,38 @@ listen(Parent, Debug, ListenSocket) ->
|
|||||||
%% The service loop itself. This is the service state. The process blocks on receive
|
%% The service loop itself. This is the service state. The process blocks on receive
|
||||||
%% of Erlang messages, TCP segments being received themselves as Erlang messages.
|
%% of Erlang messages, TCP segments being received themselves as Erlang messages.
|
||||||
|
|
||||||
loop(Parent, Debug, State = #s{socket = Socket}) ->
|
loop(Parent, Debug, State = #s{socket = Socket, received = Received}) ->
|
||||||
ok = inet:setopts(Socket, [{active, once}]),
|
ok = inet:setopts(Socket, [{active, once}]),
|
||||||
receive
|
receive
|
||||||
{tcp, Socket, Message} ->
|
{tcp, Socket, Message} ->
|
||||||
ok = io:format("~p received: ~tp~n", [self(), Message]),
|
ok = io:format("~p received: ~tp~n", [self(), Message]),
|
||||||
ok = gh_client_man:echo(Message),
|
%% Received exists because web browsers usually use the same
|
||||||
loop(Parent, Debug, State);
|
%% acceptor socket for sequential requests
|
||||||
|
%%
|
||||||
|
%% QHL parses a request off the socket, and consumes all the data
|
||||||
|
%% pertinent to said task. Any additional data it finds on the
|
||||||
|
%% socket it hands back to us.
|
||||||
|
%%
|
||||||
|
%% That additional data, as I said, is usually the next request.
|
||||||
|
%%
|
||||||
|
%% We store that in our process state in the received=Received field
|
||||||
|
Message2 =
|
||||||
|
case Received of
|
||||||
|
none -> Message;
|
||||||
|
_ -> <<Received/binary, Message/binary>>
|
||||||
|
end,
|
||||||
|
%% beware: wrong typespec in QHL 0.1.0
|
||||||
|
%% see: https://git.qpq.swiss/QPQ-AG/QHL/pulls/1
|
||||||
|
case qhl:parse(Socket, Message2) of
|
||||||
|
{ok, Request, NewReceived} ->
|
||||||
|
ok = handle_request(Socket, Request),
|
||||||
|
NewState = State#s{received = NewReceived},
|
||||||
|
loop(Parent, Debug, NewState);
|
||||||
|
{error, Reason} ->
|
||||||
|
io:format("~p error: ~tp~n", [self(), Reason]),
|
||||||
|
ok = http_err(Socket, 500),
|
||||||
|
exit(normal)
|
||||||
|
end;
|
||||||
{tcp_closed, Socket} ->
|
{tcp_closed, Socket} ->
|
||||||
ok = io:format("~p Socket closed, retiring.~n", [self()]),
|
ok = io:format("~p Socket closed, retiring.~n", [self()]),
|
||||||
exit(normal);
|
exit(normal);
|
||||||
@ -190,3 +218,85 @@ system_get_state(State) -> {ok, State}.
|
|||||||
|
|
||||||
system_replace_state(StateFun, State) ->
|
system_replace_state(StateFun, State) ->
|
||||||
{ok, StateFun(State), State}.
|
{ok, StateFun(State), State}.
|
||||||
|
|
||||||
|
|
||||||
|
%%%-------------------------------------------
|
||||||
|
%%% http request handling
|
||||||
|
%%%-------------------------------------------
|
||||||
|
|
||||||
|
-spec handle_request(Socket, Request) -> ok
|
||||||
|
when Socket :: gen_tcp:socket(),
|
||||||
|
Request :: #request{}.
|
||||||
|
|
||||||
|
%% ref: https://git.qpq.swiss/QPQ-AG/QHL/src/commit/7f77f9e3b19f58006df88a2a601e85835d300c37/include/http.hrl
|
||||||
|
|
||||||
|
handle_request(Socket, #request{method = get, path = <<"/">>}) ->
|
||||||
|
IndexHtmlPath = filename:join([zx:get_home(), "priv", "index.html"]),
|
||||||
|
case file:read_file(IndexHtmlPath) of
|
||||||
|
{ok, ResponseBody} ->
|
||||||
|
%% see https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Messages#http_responses
|
||||||
|
Headers = [{"content-type", "text/html"}],
|
||||||
|
Response = #response{headers = Headers,
|
||||||
|
body = ResponseBody},
|
||||||
|
respond(Socket, Response);
|
||||||
|
Error ->
|
||||||
|
io:format("~p error: ~p~n", [self(), Error]),
|
||||||
|
http_err(Socket, 500)
|
||||||
|
end;
|
||||||
|
handle_request(Socket, _) ->
|
||||||
|
http_err(Socket, 404).
|
||||||
|
|
||||||
|
|
||||||
|
http_err(Socket, 404) ->
|
||||||
|
HtmlPath = filename:join([zx:get_home(), "priv", "404.html"]),
|
||||||
|
{ok, ResponseBody} = file:read_file(HtmlPath),
|
||||||
|
Headers = [{"content-type", "text/html"}],
|
||||||
|
Response = #response{headers = Headers,
|
||||||
|
code = 404,
|
||||||
|
body = ResponseBody},
|
||||||
|
respond(Socket, Response);
|
||||||
|
% default error is 500
|
||||||
|
http_err(Socket, _) ->
|
||||||
|
HtmlPath = filename:join([zx:get_home(), "priv", "500.html"]),
|
||||||
|
{ok, ResponseBody} = file:read_file(HtmlPath),
|
||||||
|
Headers = [{"content-type", "text/html"}],
|
||||||
|
Response = #response{headers = Headers,
|
||||||
|
code = 500,
|
||||||
|
body = ResponseBody},
|
||||||
|
respond(Socket, Response).
|
||||||
|
|
||||||
|
|
||||||
|
respond(Socket, R = #response{code = Code, headers = Headers, body = Body}) ->
|
||||||
|
Slogan = slogan(Code),
|
||||||
|
ContentLength = byte_size(Body),
|
||||||
|
DefaultHeaders = [{"date", qhl:ridiculous_web_date()},
|
||||||
|
{"content-length", integer_to_list(ContentLength)}],
|
||||||
|
Headers2 = merge_headers(DefaultHeaders, Headers),
|
||||||
|
really_respond(Socket, R#response{slogan = Slogan,
|
||||||
|
headers = Headers2}).
|
||||||
|
|
||||||
|
|
||||||
|
really_respond(Socket, #response{code = Code, slogan = Slogan, headers = Headers, body = Body}) ->
|
||||||
|
Response =
|
||||||
|
["HTTP/1.1 ", integer_to_list(Code), " ", Slogan, "\r\n",
|
||||||
|
render_headers(Headers), "\r\n",
|
||||||
|
Body],
|
||||||
|
gen_tcp:send(Socket, Response).
|
||||||
|
|
||||||
|
|
||||||
|
merge_headers(Defaults, Overwrites) ->
|
||||||
|
DefaultsMap = proplists:to_map(Defaults),
|
||||||
|
OverwritesMap = proplists:to_map(Overwrites),
|
||||||
|
FinalMap = maps:merge(DefaultsMap, OverwritesMap),
|
||||||
|
proplists:from_map(FinalMap).
|
||||||
|
|
||||||
|
render_headers([{K, V} | Rest]) ->
|
||||||
|
[K, ": ", V, "\r\n",
|
||||||
|
render_headers(Rest)];
|
||||||
|
render_headers([]) ->
|
||||||
|
[].
|
||||||
|
|
||||||
|
|
||||||
|
slogan(200) -> "OK";
|
||||||
|
slogan(404) -> "Not Found";
|
||||||
|
slogan(500) -> "Internal Server Error".
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user