Compare commits
10 Commits
d99fefcd15
...
51c081fb55
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
51c081fb55 | ||
|
|
2cfa90beb1 | ||
|
|
f73756c15a | ||
|
|
d26cb75331 | ||
|
|
46aacfb621 | ||
|
|
6bfd19e027 | ||
|
|
c270e18ed0 | ||
|
|
46b93158db | ||
|
|
6ba256f016 | ||
|
|
7299dbc9f1 |
19
LICENSE
Normal file
19
LICENSE
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
Copyright (c) 2025-2026 QPQ AG
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
this software and associated documentation files (the "Software"), to deal in
|
||||||
|
the Software without restriction, including without limitation the rights to
|
||||||
|
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
|
||||||
|
of the Software, and to permit persons to whom the Software is furnished to do
|
||||||
|
so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 219 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 12 KiB |
BIN
etc/observer.png
BIN
etc/observer.png
Binary file not shown.
|
Before Width: | Height: | Size: 14 KiB |
@ -1,51 +0,0 @@
|
|||||||
-module(pingpong).
|
|
||||||
-export([main/0]).
|
|
||||||
|
|
||||||
% this runs two processes and has them talk to each other
|
|
||||||
% output:
|
|
||||||
%
|
|
||||||
% 2> pingpong:main().
|
|
||||||
% <0.96.0> received {pong, <0.97.0>, 10}
|
|
||||||
% <0.97.0> received {ping, <0.96.0>, 9}
|
|
||||||
% <0.96.0> received {pong, <0.97.0>, 8}
|
|
||||||
% <0.97.0> received {ping, <0.96.0>, 7}
|
|
||||||
% <0.96.0> received {pong, <0.97.0>, 6}
|
|
||||||
% <0.97.0> received {ping, <0.96.0>, 5}
|
|
||||||
% <0.96.0> received {pong, <0.97.0>, 4}
|
|
||||||
% <0.97.0> received {ping, <0.96.0>, 3}
|
|
||||||
% <0.96.0> received {pong, <0.97.0>, 2}
|
|
||||||
% <0.97.0> received {ping, <0.96.0>, 1}
|
|
||||||
% <0.96.0> received {pong, <0.97.0>, 0}
|
|
||||||
|
|
||||||
|
|
||||||
main() ->
|
|
||||||
PingerPID = spawn(fun pinger/0),
|
|
||||||
PongerPID = spawn(fun ponger/0),
|
|
||||||
PingerPID ! {pong, PongerPID, 10}.
|
|
||||||
|
|
||||||
pinger() ->
|
|
||||||
receive
|
|
||||||
{pong, PID, N} ->
|
|
||||||
io:format("~p received {pong, ~p, ~p}~n", [self(), PID, N]),
|
|
||||||
% ignore once we get to 0 or lower
|
|
||||||
case N > 0 of
|
|
||||||
true -> PID ! {ping, self(), N-1};
|
|
||||||
false -> ok
|
|
||||||
end,
|
|
||||||
% once we're done, go back to the top
|
|
||||||
pinger()
|
|
||||||
end.
|
|
||||||
|
|
||||||
|
|
||||||
ponger() ->
|
|
||||||
receive
|
|
||||||
{ping, PID, N} ->
|
|
||||||
io:format("~p received {ping, ~p, ~p}~n", [self(), PID, N]),
|
|
||||||
% ignore once we get to 0 or lower
|
|
||||||
case N > 0 of
|
|
||||||
true -> PID ! {pong, self(), N-1};
|
|
||||||
false -> ok
|
|
||||||
end,
|
|
||||||
% once we're done, go back to the top
|
|
||||||
ponger()
|
|
||||||
end.
|
|
||||||
BIN
etc/v001-end.png
BIN
etc/v001-end.png
Binary file not shown.
|
Before Width: | Height: | Size: 113 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 219 KiB |
1
gex_httpd/.gitignore
vendored
1
gex_httpd/.gitignore
vendored
@ -1,3 +1,4 @@
|
|||||||
|
priv/keypair.eterms
|
||||||
.eunit
|
.eunit
|
||||||
deps
|
deps
|
||||||
*.o
|
*.o
|
||||||
|
|||||||
26
gex_httpd/README.md
Normal file
26
gex_httpd/README.md
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
`gex_httpd`: Gajumaru Exchange HTTP Daemon
|
||||||
|
=====================================================================
|
||||||
|
|
||||||
|
GOALS
|
||||||
|
====================================================================
|
||||||
|
|
||||||
|
|
||||||
|
GOAL STACK
|
||||||
|
--------------------------------------------------------------------
|
||||||
|
|
||||||
|
- use `gh_sfc` in `gh_client`
|
||||||
|
- don't spam filesystem for 404/500
|
||||||
|
|
||||||
|
|
||||||
|
TODONE
|
||||||
|
--------------------------------------------------------------------
|
||||||
|
|
||||||
|
- ~~replace `io:format` calls with zx log~~
|
||||||
|
- ~~write out call paths for `gh_sfc`~~
|
||||||
|
|
||||||
|
|
||||||
|
GOAL QUEUE
|
||||||
|
--------------------------------------------------------------------
|
||||||
|
|
||||||
|
- mit license
|
||||||
|
- copyright/author bullshit in each module
|
||||||
@ -5,5 +5,6 @@
|
|||||||
{applications,[stdlib,kernel]},
|
{applications,[stdlib,kernel]},
|
||||||
{vsn,"0.1.0"},
|
{vsn,"0.1.0"},
|
||||||
{modules,[gex_httpd,gh_client,gh_client_man,gh_client_sup,
|
{modules,[gex_httpd,gh_client,gh_client_man,gh_client_sup,
|
||||||
gh_clients,gh_sup]},
|
gh_clients,gh_ct,gh_sfc,gh_sfc_cache,gh_sfc_entry,
|
||||||
|
gh_sup]},
|
||||||
{mod,{gex_httpd,[]}}]}.
|
{mod,{gex_httpd,[]}}]}.
|
||||||
|
|||||||
@ -1,11 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<title>QHL: 404</title>
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body>
|
|
||||||
<h1>404 Not Found</h1>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@ -1,11 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<title>QHL: 500</title>
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body>
|
|
||||||
<h1>500 Internal Server Error</h1>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
16
gex_httpd/priv/ct/hello.aes
Normal file
16
gex_httpd/priv/ct/hello.aes
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
/**
|
||||||
|
* Hello world contract in sophia
|
||||||
|
*
|
||||||
|
* Copyright (C) 2025, QPQ AG
|
||||||
|
*/
|
||||||
|
|
||||||
|
@compiler == 9.0.0
|
||||||
|
|
||||||
|
contract Hello =
|
||||||
|
type state = unit
|
||||||
|
|
||||||
|
entrypoint init(): state =
|
||||||
|
()
|
||||||
|
|
||||||
|
entrypoint hello(): string =
|
||||||
|
"hello"
|
||||||
@ -3,20 +3,26 @@
|
|||||||
%%% @end
|
%%% @end
|
||||||
|
|
||||||
-module(gex_httpd).
|
-module(gex_httpd).
|
||||||
-vsn("0.1.0").
|
|
||||||
-behavior(application).
|
-behavior(application).
|
||||||
-author("Peter Harpending <peterharpending@qpq.swiss>").
|
|
||||||
-copyright("Peter Harpending <peterharpending@qpq.swiss>").
|
|
||||||
|
|
||||||
|
-vsn("0.1.0").
|
||||||
|
-author("Peter Harpending <peterharpending@qpq.swiss>").
|
||||||
|
-copyright("2025-2026 QPQ AG").
|
||||||
|
-license("MIT").
|
||||||
|
|
||||||
%% for our edification
|
%% for our edification
|
||||||
-export([listen/1, ignore/0]).
|
-export([listen/1, ignore/0]).
|
||||||
-export([start/0]).
|
|
||||||
|
|
||||||
%% erlang expects us to export these functions
|
%% erlang expects us to export these functions
|
||||||
-export([start/2, stop/1]).
|
-export([start/2, stop/1]).
|
||||||
|
|
||||||
|
|
||||||
|
-include("$zx_include/zx_logger.hrl").
|
||||||
|
|
||||||
|
%------------------------------------------------------
|
||||||
|
% API
|
||||||
|
%------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
-spec listen(PortNum) -> Result
|
-spec listen(PortNum) -> Result
|
||||||
when PortNum :: inet:port_num(),
|
when PortNum :: inet:port_num(),
|
||||||
@ -38,27 +44,6 @@ ignore() ->
|
|||||||
gh_client_man:ignore().
|
gh_client_man:ignore().
|
||||||
|
|
||||||
|
|
||||||
-spec start() -> ok.
|
|
||||||
%% @doc
|
|
||||||
%% Start the server in an "ignore" state.
|
|
||||||
|
|
||||||
start() ->
|
|
||||||
ok = application:ensure_started(sasl),
|
|
||||||
ok = application:start(gex_httpd),
|
|
||||||
io:format("Starting...").
|
|
||||||
|
|
||||||
|
|
||||||
%-spec start(PortNum) -> ok
|
|
||||||
% when PortNum :: inet:port_number().
|
|
||||||
%%% @doc
|
|
||||||
%%% Start the server and begin listening immediately. Slightly more convenient when
|
|
||||||
%%% playing around in the shell.
|
|
||||||
%
|
|
||||||
%start(PortNum) ->
|
|
||||||
% ok = start(),
|
|
||||||
% ok = gh_client_man:listen(PortNum),
|
|
||||||
% io:format("Startup complete, listening on ~w~n", [PortNum]).
|
|
||||||
%
|
|
||||||
|
|
||||||
-spec start(normal, term()) -> {ok, pid()}.
|
-spec start(normal, term()) -> {ok, pid()}.
|
||||||
%% @private
|
%% @private
|
||||||
@ -68,27 +53,25 @@ start() ->
|
|||||||
|
|
||||||
start(normal, _Args) ->
|
start(normal, _Args) ->
|
||||||
Result = gh_sup:start_link(),
|
Result = gh_sup:start_link(),
|
||||||
% auto-listen to port 8000
|
|
||||||
ok = hz(),
|
ok = hz(),
|
||||||
|
% auto-listen to port 8000
|
||||||
ok = listen(8000),
|
ok = listen(8000),
|
||||||
Result.
|
Result.
|
||||||
|
|
||||||
|
|
||||||
hz() ->
|
hz() ->
|
||||||
ok = application:ensure_started(hakuzaru),
|
ok = application:ensure_started(hakuzaru),
|
||||||
ok = hz:chain_nodes([testnet_node()]),
|
%TestnetIP = {84, 46, 242, 9},
|
||||||
ok = zx:tell("hz status: ~tp", [hz:status()]),
|
% fuck bulgaria
|
||||||
|
% TestnetIP = "groot.testnet.gajumaru.io",
|
||||||
|
% TestnetPort = 3013,
|
||||||
|
% japan good
|
||||||
|
TestnetIP = "tsuriai.jp",
|
||||||
|
TestnetPort = 4013,
|
||||||
|
TestnetNode = {TestnetIP, TestnetPort},
|
||||||
|
ok = hz:chain_nodes([TestnetNode]),
|
||||||
|
ok = tell("hz status: ~tp", [hz:status()]),
|
||||||
ok.
|
ok.
|
||||||
|
|
||||||
testnet_ip() ->
|
|
||||||
{84, 46, 242, 9}.
|
|
||||||
|
|
||||||
testnet_port() ->
|
|
||||||
3013.
|
|
||||||
|
|
||||||
testnet_node() ->
|
|
||||||
{testnet_ip(), testnet_port()}.
|
|
||||||
|
|
||||||
|
|
||||||
-spec stop(term()) -> ok.
|
-spec stop(term()) -> ok.
|
||||||
%% @private
|
%% @private
|
||||||
|
|||||||
@ -14,9 +14,11 @@
|
|||||||
%%% @end
|
%%% @end
|
||||||
|
|
||||||
-module(gh_client).
|
-module(gh_client).
|
||||||
|
|
||||||
-vsn("0.1.0").
|
-vsn("0.1.0").
|
||||||
-author("Peter Harpending <peterharpending@qpq.swiss>").
|
-author("Peter Harpending <peterharpending@qpq.swiss>").
|
||||||
-copyright("Peter Harpending <peterharpending@qpq.swiss>").
|
-copyright("2025-2026 QPQ AG").
|
||||||
|
-license("MIT").
|
||||||
|
|
||||||
|
|
||||||
-export([start/1]).
|
-export([start/1]).
|
||||||
@ -24,6 +26,7 @@
|
|||||||
-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("$zx_include/zx_logger.hrl").
|
||||||
-include("http.hrl").
|
-include("http.hrl").
|
||||||
|
|
||||||
|
|
||||||
@ -85,7 +88,7 @@ start_link(ListenSocket) ->
|
|||||||
%% call to listen/3.
|
%% call to listen/3.
|
||||||
|
|
||||||
init(Parent, ListenSocket) ->
|
init(Parent, ListenSocket) ->
|
||||||
ok = io:format("~p Listening.~n", [self()]),
|
ok = tell("~p Listening.~n", [self()]),
|
||||||
Debug = sys:debug_options([]),
|
Debug = sys:debug_options([]),
|
||||||
ok = proc_lib:init_ack(Parent, {ok, self()}),
|
ok = proc_lib:init_ack(Parent, {ok, self()}),
|
||||||
listen(Parent, Debug, ListenSocket).
|
listen(Parent, Debug, ListenSocket).
|
||||||
@ -109,12 +112,12 @@ listen(Parent, Debug, ListenSocket) ->
|
|||||||
{ok, Socket} ->
|
{ok, Socket} ->
|
||||||
{ok, _} = start(ListenSocket),
|
{ok, _} = start(ListenSocket),
|
||||||
{ok, Peer} = inet:peername(Socket),
|
{ok, Peer} = inet:peername(Socket),
|
||||||
ok = io:format("~p Connection accepted from: ~p~n", [self(), Peer]),
|
ok = tell("~p Connection accepted from: ~p~n", [self(), Peer]),
|
||||||
ok = gh_client_man:enroll(),
|
ok = gh_client_man:enroll(),
|
||||||
State = #s{socket = Socket},
|
State = #s{socket = Socket},
|
||||||
loop(Parent, Debug, State);
|
loop(Parent, Debug, State);
|
||||||
{error, closed} ->
|
{error, closed} ->
|
||||||
ok = io:format("~p Retiring: Listen socket closed.~n", [self()]),
|
ok = log("~p Retiring: Listen socket closed.~n", [self()]),
|
||||||
exit(normal)
|
exit(normal)
|
||||||
end.
|
end.
|
||||||
|
|
||||||
@ -131,7 +134,6 @@ 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]),
|
|
||||||
%% Received exists because web browsers usually use the same
|
%% Received exists because web browsers usually use the same
|
||||||
%% acceptor socket for sequential requests
|
%% acceptor socket for sequential requests
|
||||||
%%
|
%%
|
||||||
@ -151,21 +153,27 @@ loop(Parent, Debug, State = #s{socket = Socket, received = Received}) ->
|
|||||||
%% see: https://git.qpq.swiss/QPQ-AG/QHL/pulls/1
|
%% see: https://git.qpq.swiss/QPQ-AG/QHL/pulls/1
|
||||||
case qhl:parse(Socket, Message2) of
|
case qhl:parse(Socket, Message2) of
|
||||||
{ok, Request, NewReceived} ->
|
{ok, Request, NewReceived} ->
|
||||||
ok = handle_request(Socket, Request),
|
try
|
||||||
|
ok = handle_request(Socket, Request)
|
||||||
|
catch
|
||||||
|
X:Y:Z ->
|
||||||
|
tell(error, "~tp ~tp: CRASH: ~tp:~tp:~tp, returning 500", [?MODULE, self(), X, Y, Z]),
|
||||||
|
http_err(Socket, 500)
|
||||||
|
end,
|
||||||
NewState = State#s{received = NewReceived},
|
NewState = State#s{received = NewReceived},
|
||||||
loop(Parent, Debug, NewState);
|
loop(Parent, Debug, NewState);
|
||||||
{error, Reason} ->
|
{error, Reason} ->
|
||||||
io:format("~p error: ~tp~n", [self(), Reason]),
|
tell(warning, "~p ~p: http parse error: ~tp~n", [?MODULE, self(), Reason]),
|
||||||
ok = http_err(Socket, 500),
|
ok = http_err(Socket, 500),
|
||||||
exit(normal)
|
exit(normal)
|
||||||
end;
|
end;
|
||||||
{tcp_closed, Socket} ->
|
{tcp_closed, Socket} ->
|
||||||
ok = io:format("~p Socket closed, retiring.~n", [self()]),
|
ok = log(warning, "~p ~p: Socket closed, retiring.~n", [?MODULE, self()]),
|
||||||
exit(normal);
|
exit(normal);
|
||||||
{system, From, Request} ->
|
{system, From, Request} ->
|
||||||
sys:handle_system_msg(Request, From, Parent, ?MODULE, Debug, State);
|
sys:handle_system_msg(Request, From, Parent, ?MODULE, Debug, State);
|
||||||
Unexpected ->
|
Unexpected ->
|
||||||
ok = io:format("~p Unexpected message: ~tp", [self(), Unexpected]),
|
ok = tell("~p ~p: Unexpected message: ~tp", [?MODULE, self(), Unexpected]),
|
||||||
loop(Parent, Debug, State)
|
loop(Parent, Debug, State)
|
||||||
end.
|
end.
|
||||||
|
|
||||||
@ -230,26 +238,54 @@ system_replace_state(StateFun, State) ->
|
|||||||
|
|
||||||
%% ref: https://git.qpq.swiss/QPQ-AG/QHL/src/commit/7f77f9e3b19f58006df88a2a601e85835d300c37/include/http.hrl
|
%% ref: https://git.qpq.swiss/QPQ-AG/QHL/src/commit/7f77f9e3b19f58006df88a2a601e85835d300c37/include/http.hrl
|
||||||
|
|
||||||
handle_request(Socket, #request{method = get, path = <<"/">>}) ->
|
handle_request(Socket, #request{method = get, path = Path}) ->
|
||||||
IndexHtmlPath = filename:join([zx:get_home(), "priv", "index.html"]),
|
% future-proofing for hardcoded paths
|
||||||
case file:read_file(IndexHtmlPath) of
|
case Path of
|
||||||
{ok, ResponseBody} ->
|
_ -> handle_static(Socket, Path)
|
||||||
%% 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;
|
end;
|
||||||
handle_request(Socket, _) ->
|
handle_request(Socket, _) ->
|
||||||
http_err(Socket, 404).
|
http_err(Socket, 404).
|
||||||
|
|
||||||
|
|
||||||
|
-spec handle_static(Socket, Path) -> ok
|
||||||
|
when Socket :: gen_tcp:socket(),
|
||||||
|
Path :: binary().
|
||||||
|
|
||||||
|
handle_static(Socket, <<"/">>) ->
|
||||||
|
handle_static(Socket, <<"/index.html">>);
|
||||||
|
handle_static(Socket, Path) ->
|
||||||
|
case gh_sfc:query(Path) of
|
||||||
|
{found, Entry} -> handle_entry(Socket, Entry);
|
||||||
|
not_found -> http_err(Socket, 404)
|
||||||
|
end.
|
||||||
|
|
||||||
|
|
||||||
|
-spec handle_entry(Socket, Entry) -> ok
|
||||||
|
when Socket :: gen_tcp:socket(),
|
||||||
|
Entry :: gh_sfc_entry:entry().
|
||||||
|
|
||||||
|
handle_entry(Socket, Entry) ->
|
||||||
|
% -type encoding() :: none | gzip.
|
||||||
|
% -record(e, {fs_path :: file:filename(),
|
||||||
|
% last_modified :: file:date_time(),
|
||||||
|
% mime_type :: string(),
|
||||||
|
% encoding :: encoding(),
|
||||||
|
% contents :: binary()}).
|
||||||
|
Encoding = gh_sfc_entry:encoding(Entry) ,
|
||||||
|
MimeType = gh_sfc_entry:mime_type(Entry),
|
||||||
|
Contents = gh_sfc_entry:contents(Entry),
|
||||||
|
Headers0 =
|
||||||
|
case Encoding of
|
||||||
|
gzip -> [{"content-encoding", "gzip"}];
|
||||||
|
none -> []
|
||||||
|
end,
|
||||||
|
Headers1 = [{"content-type", MimeType} | Headers0],
|
||||||
|
Response = #response{headers = Headers1,
|
||||||
|
body = Contents},
|
||||||
|
respond(Socket, Response).
|
||||||
|
|
||||||
http_err(Socket, 404) ->
|
http_err(Socket, 404) ->
|
||||||
HtmlPath = filename:join([zx:get_home(), "priv", "404.html"]),
|
ResponseBody = bdy_404(),
|
||||||
{ok, ResponseBody} = file:read_file(HtmlPath),
|
|
||||||
Headers = [{"content-type", "text/html"}],
|
Headers = [{"content-type", "text/html"}],
|
||||||
Response = #response{headers = Headers,
|
Response = #response{headers = Headers,
|
||||||
code = 404,
|
code = 404,
|
||||||
@ -257,8 +293,7 @@ http_err(Socket, 404) ->
|
|||||||
respond(Socket, Response);
|
respond(Socket, Response);
|
||||||
% default error is 500
|
% default error is 500
|
||||||
http_err(Socket, _) ->
|
http_err(Socket, _) ->
|
||||||
HtmlPath = filename:join([zx:get_home(), "priv", "500.html"]),
|
ResponseBody = bdy_500(),
|
||||||
{ok, ResponseBody} = file:read_file(HtmlPath),
|
|
||||||
Headers = [{"content-type", "text/html"}],
|
Headers = [{"content-type", "text/html"}],
|
||||||
Response = #response{headers = Headers,
|
Response = #response{headers = Headers,
|
||||||
code = 500,
|
code = 500,
|
||||||
@ -266,11 +301,39 @@ http_err(Socket, _) ->
|
|||||||
respond(Socket, Response).
|
respond(Socket, Response).
|
||||||
|
|
||||||
|
|
||||||
|
bdy_404() ->
|
||||||
|
["<!DOCTYPE html>"
|
||||||
|
"<html lang=\"en\">"
|
||||||
|
"<head>"
|
||||||
|
"<meta charset=\"UTF-8\">"
|
||||||
|
"<title>QHL: 404</title>"
|
||||||
|
"</head>"
|
||||||
|
"<body>"
|
||||||
|
"<h1>404 Not Found</h1>"
|
||||||
|
"</body>"
|
||||||
|
"</html>"].
|
||||||
|
|
||||||
|
|
||||||
|
bdy_500() ->
|
||||||
|
["<!DOCTYPE html>"
|
||||||
|
"<html lang=\"en\">"
|
||||||
|
"<head>"
|
||||||
|
"<meta charset=\"UTF-8\">"
|
||||||
|
"<title>QHL: 500 Internal Server Error</title>"
|
||||||
|
"</head>"
|
||||||
|
"<body>"
|
||||||
|
"<h1>500 Internal Server Error</h1>"
|
||||||
|
"</body>"
|
||||||
|
"</html>"].
|
||||||
|
|
||||||
|
|
||||||
respond(Socket, R = #response{code = Code, headers = Headers, body = Body}) ->
|
respond(Socket, R = #response{code = Code, headers = Headers, body = Body}) ->
|
||||||
Slogan = slogan(Code),
|
Slogan = slogan(Code),
|
||||||
ContentLength = byte_size(Body),
|
BodyBytes = iolist_to_binary(Body),
|
||||||
DefaultHeaders = [{"date", qhl:ridiculous_web_date()},
|
ContentLength = byte_size(BodyBytes),
|
||||||
{"content-length", integer_to_list(ContentLength)}],
|
DefaultHeaders = [{"Server", "gex_httpd 0.1.0"},
|
||||||
|
{"Date", qhl:ridiculous_web_date()},
|
||||||
|
{"Content-Length", integer_to_list(ContentLength)}],
|
||||||
Headers2 = merge_headers(DefaultHeaders, Headers),
|
Headers2 = merge_headers(DefaultHeaders, Headers),
|
||||||
really_respond(Socket, R#response{slogan = Slogan,
|
really_respond(Socket, R#response{slogan = Slogan,
|
||||||
headers = Headers2}).
|
headers = Headers2}).
|
||||||
|
|||||||
@ -10,10 +10,12 @@
|
|||||||
%%% @end
|
%%% @end
|
||||||
|
|
||||||
-module(gh_client_man).
|
-module(gh_client_man).
|
||||||
-vsn("0.1.0").
|
|
||||||
-behavior(gen_server).
|
-behavior(gen_server).
|
||||||
|
|
||||||
|
-vsn("0.1.0").
|
||||||
-author("Peter Harpending <peterharpending@qpq.swiss>").
|
-author("Peter Harpending <peterharpending@qpq.swiss>").
|
||||||
-copyright("Peter Harpending <peterharpending@qpq.swiss>").
|
-copyright("2025-2026 QPQ AG").
|
||||||
|
-license("MIT").
|
||||||
|
|
||||||
|
|
||||||
-export([listen/1, ignore/0]).
|
-export([listen/1, ignore/0]).
|
||||||
@ -23,6 +25,8 @@
|
|||||||
code_change/3, terminate/2]).
|
code_change/3, terminate/2]).
|
||||||
|
|
||||||
|
|
||||||
|
-include("$zx_include/zx_logger.hrl").
|
||||||
|
|
||||||
%%% Type and Record Definitions
|
%%% Type and Record Definitions
|
||||||
|
|
||||||
|
|
||||||
@ -93,7 +97,7 @@ start_link() ->
|
|||||||
%% preparatory work necessary for proper function.
|
%% preparatory work necessary for proper function.
|
||||||
|
|
||||||
init(none) ->
|
init(none) ->
|
||||||
ok = io:format("Starting.~n"),
|
ok = tell("~p ~p: Starting.~n", [?MODULE, self()]),
|
||||||
State = #s{},
|
State = #s{},
|
||||||
{ok, State}.
|
{ok, State}.
|
||||||
|
|
||||||
@ -119,7 +123,7 @@ handle_call({listen, PortNum}, _, State) ->
|
|||||||
{Response, NewState} = do_listen(PortNum, State),
|
{Response, NewState} = do_listen(PortNum, State),
|
||||||
{reply, Response, NewState};
|
{reply, Response, NewState};
|
||||||
handle_call(Unexpected, From, State) ->
|
handle_call(Unexpected, From, State) ->
|
||||||
ok = io:format("~p Unexpected call from ~tp: ~tp~n", [self(), From, Unexpected]),
|
ok = tell("~p ~p Unexpected call from ~tp: ~tp~n", [?MODULE, self(), From, Unexpected]),
|
||||||
{noreply, State}.
|
{noreply, State}.
|
||||||
|
|
||||||
|
|
||||||
@ -138,7 +142,7 @@ handle_cast(ignore, State) ->
|
|||||||
NewState = do_ignore(State),
|
NewState = do_ignore(State),
|
||||||
{noreply, NewState};
|
{noreply, NewState};
|
||||||
handle_cast(Unexpected, State) ->
|
handle_cast(Unexpected, State) ->
|
||||||
ok = io:format("~p Unexpected cast: ~tp~n", [self(), Unexpected]),
|
ok = tell("~p Unexpected cast: ~tp~n", [self(), Unexpected]),
|
||||||
{noreply, State}.
|
{noreply, State}.
|
||||||
|
|
||||||
|
|
||||||
@ -154,7 +158,7 @@ handle_info({'DOWN', Mon, process, Pid, Reason}, State) ->
|
|||||||
NewState = handle_down(Mon, Pid, Reason, State),
|
NewState = handle_down(Mon, Pid, Reason, State),
|
||||||
{noreply, NewState};
|
{noreply, NewState};
|
||||||
handle_info(Unexpected, State) ->
|
handle_info(Unexpected, State) ->
|
||||||
ok = io:format("~p Unexpected info: ~tp~n", [self(), Unexpected]),
|
ok = tell("~p Unexpected info: ~tp~n", [self(), Unexpected]),
|
||||||
{noreply, State}.
|
{noreply, State}.
|
||||||
|
|
||||||
|
|
||||||
@ -206,7 +210,6 @@ terminate(_, _) ->
|
|||||||
do_listen(PortNum, State = #s{port_num = none}) ->
|
do_listen(PortNum, State = #s{port_num = none}) ->
|
||||||
SocketOptions =
|
SocketOptions =
|
||||||
[inet6,
|
[inet6,
|
||||||
{packet, line},
|
|
||||||
{active, once},
|
{active, once},
|
||||||
{mode, binary},
|
{mode, binary},
|
||||||
{keepalive, true},
|
{keepalive, true},
|
||||||
@ -215,7 +218,7 @@ do_listen(PortNum, State = #s{port_num = none}) ->
|
|||||||
{ok, _} = gh_client:start(Listener),
|
{ok, _} = gh_client:start(Listener),
|
||||||
{ok, State#s{port_num = PortNum, listener = Listener}};
|
{ok, State#s{port_num = PortNum, listener = Listener}};
|
||||||
do_listen(_, State = #s{port_num = PortNum}) ->
|
do_listen(_, State = #s{port_num = PortNum}) ->
|
||||||
ok = io:format("~p Already listening on ~p~n", [self(), PortNum]),
|
ok = tell("~p ~p: Already listening on ~p~n", [?MODULE, self(), PortNum]),
|
||||||
{{error, {listening, PortNum}}, State}.
|
{{error, {listening, PortNum}}, State}.
|
||||||
|
|
||||||
|
|
||||||
@ -241,7 +244,7 @@ do_enroll(Pid, State = #s{clients = Clients}) ->
|
|||||||
case lists:member(Pid, Clients) of
|
case lists:member(Pid, Clients) of
|
||||||
false ->
|
false ->
|
||||||
Mon = monitor(process, Pid),
|
Mon = monitor(process, Pid),
|
||||||
ok = io:format("Monitoring ~tp @ ~tp~n", [Pid, Mon]),
|
ok = tell("Monitoring ~tp @ ~tp~n", [Pid, Mon]),
|
||||||
State#s{clients = [Pid | Clients]};
|
State#s{clients = [Pid | Clients]};
|
||||||
true ->
|
true ->
|
||||||
State
|
State
|
||||||
@ -268,6 +271,6 @@ handle_down(Mon, Pid, Reason, State = #s{clients = Clients}) ->
|
|||||||
State#s{clients = NewClients};
|
State#s{clients = NewClients};
|
||||||
false ->
|
false ->
|
||||||
Unexpected = {'DOWN', Mon, process, Pid, Reason},
|
Unexpected = {'DOWN', Mon, process, Pid, Reason},
|
||||||
ok = io:format("~p Unexpected info: ~tp~n", [self(), Unexpected]),
|
ok = tell("~p Unexpected info: ~tp~n", [self(), Unexpected]),
|
||||||
State
|
State
|
||||||
end.
|
end.
|
||||||
|
|||||||
@ -14,12 +14,12 @@
|
|||||||
%%% @end
|
%%% @end
|
||||||
|
|
||||||
-module(gh_client_sup).
|
-module(gh_client_sup).
|
||||||
-vsn("0.1.0").
|
|
||||||
-behaviour(supervisor).
|
-behaviour(supervisor).
|
||||||
|
|
||||||
|
-vsn("0.1.0").
|
||||||
-author("Peter Harpending <peterharpending@qpq.swiss>").
|
-author("Peter Harpending <peterharpending@qpq.swiss>").
|
||||||
-copyright("Peter Harpending <peterharpending@qpq.swiss>").
|
-copyright("2025-2026 QPQ AG").
|
||||||
|
-license("MIT").
|
||||||
|
|
||||||
|
|
||||||
-export([start_acceptor/1]).
|
-export([start_acceptor/1]).
|
||||||
-export([start_link/0]).
|
-export([start_link/0]).
|
||||||
|
|||||||
@ -9,10 +9,12 @@
|
|||||||
%%% @end
|
%%% @end
|
||||||
|
|
||||||
-module(gh_clients).
|
-module(gh_clients).
|
||||||
-vsn("0.1.0").
|
|
||||||
-behavior(supervisor).
|
-behavior(supervisor).
|
||||||
|
|
||||||
|
-vsn("0.1.0").
|
||||||
-author("Peter Harpending <peterharpending@qpq.swiss>").
|
-author("Peter Harpending <peterharpending@qpq.swiss>").
|
||||||
-copyright("Peter Harpending <peterharpending@qpq.swiss>").
|
-copyright("2025-2026 QPQ AG").
|
||||||
|
-license("MIT").
|
||||||
|
|
||||||
|
|
||||||
-export([start_link/0]).
|
-export([start_link/0]).
|
||||||
|
|||||||
168
gex_httpd/src/gh_ct.erl
Normal file
168
gex_httpd/src/gh_ct.erl
Normal file
@ -0,0 +1,168 @@
|
|||||||
|
% @doc miscellaneous contract functions
|
||||||
|
%
|
||||||
|
% mostly wrappers for ec_utils and hakuzaru
|
||||||
|
-module(gh_ct).
|
||||||
|
|
||||||
|
-vsn("0.1.0").
|
||||||
|
-author("Peter Harpending <peterharpending@qpq.swiss>").
|
||||||
|
-copyright("2025-2026 QPQ AG").
|
||||||
|
-license("MIT").
|
||||||
|
|
||||||
|
-export_type([
|
||||||
|
keypair/0
|
||||||
|
]).
|
||||||
|
|
||||||
|
-export([
|
||||||
|
deploy/2,
|
||||||
|
get_pubkey_akstr/0, get_keypair/0,
|
||||||
|
keypair_file/0,
|
||||||
|
read_keypair_from_file/1, write_keypair_to_file/2, fmt_keypair/1,
|
||||||
|
fmt_pubkey_api/1,
|
||||||
|
gen_keypair/0
|
||||||
|
]).
|
||||||
|
|
||||||
|
-include("$zx_include/zx_logger.hrl").
|
||||||
|
|
||||||
|
%------------------------------------------------------
|
||||||
|
% API: types
|
||||||
|
%------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
-type keypair() :: #{public := binary(),
|
||||||
|
secret := binary()}.
|
||||||
|
|
||||||
|
|
||||||
|
%------------------------------------------------------
|
||||||
|
% API: functions
|
||||||
|
%------------------------------------------------------
|
||||||
|
|
||||||
|
-spec deploy(ContractSrcPath, InitArgs) -> Result
|
||||||
|
when ContractSrcPath :: string(),
|
||||||
|
InitArgs :: term(),
|
||||||
|
Result :: {ok, term()}
|
||||||
|
| {error, term()}. %% FIXME
|
||||||
|
|
||||||
|
deploy(ContractSrcPath, InitArgs) ->
|
||||||
|
CreatorId = get_pubkey_akstr(),
|
||||||
|
case hz:contract_create(CreatorId, ContractSrcPath, InitArgs) of
|
||||||
|
{ok, ContractCreateTx} ->
|
||||||
|
push(ContractCreateTx);
|
||||||
|
Error ->
|
||||||
|
tell(error, "gh_ct:deploy(~tp, ~tp) error: ~tp", [ContractSrcPath, InitArgs, Error]),
|
||||||
|
Error
|
||||||
|
end.
|
||||||
|
|
||||||
|
push(ContractCreateTx) ->
|
||||||
|
#{secret := SecretKey} = get_keypair(),
|
||||||
|
SignedTx = hz:sign_tx(ContractCreateTx, SecretKey),
|
||||||
|
tell(info, "pushing signed tx: ~tp", [SignedTx]),
|
||||||
|
hz:post_tx(SignedTx).
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-spec get_pubkey_akstr() -> string().
|
||||||
|
% @doc
|
||||||
|
% get our pubkey as an ak_... string
|
||||||
|
|
||||||
|
get_pubkey_akstr() ->
|
||||||
|
#{public := PK} = get_keypair(),
|
||||||
|
unicode:characters_to_list(fmt_pubkey_api(PK)).
|
||||||
|
|
||||||
|
|
||||||
|
-spec get_keypair() -> keypair().
|
||||||
|
% @doc
|
||||||
|
% if can read keypair from `keypair_file()`, do so
|
||||||
|
% otherwise generate one
|
||||||
|
%
|
||||||
|
% prints warnings if IO ops fail
|
||||||
|
|
||||||
|
get_keypair() ->
|
||||||
|
case read_keypair_from_file(keypair_file()) of
|
||||||
|
{ok, KP} ->
|
||||||
|
KP;
|
||||||
|
% probably file
|
||||||
|
ReadError ->
|
||||||
|
tell(warning, "gh_ct:get_keypair(): read error: ~tp", [ReadError]),
|
||||||
|
KP = gen_keypair(),
|
||||||
|
% try writing to file
|
||||||
|
%tell(info, "gh_ct:get_keypair(): attempting to write keypair to file...", []),
|
||||||
|
%case write_keypair_to_file(keypair_file(), KP) of
|
||||||
|
% ok -> tell(info, "gh_ct:get_keypair(): write successful!", []);
|
||||||
|
% Error -> tell(warning, "gh_ct:get_keypair(): write error: ~tp", [Error])
|
||||||
|
%end,
|
||||||
|
KP
|
||||||
|
end.
|
||||||
|
|
||||||
|
|
||||||
|
-spec keypair_file() -> string().
|
||||||
|
% @doc
|
||||||
|
% normal file where operating keypair is stored
|
||||||
|
|
||||||
|
keypair_file() ->
|
||||||
|
filename:join([zx:get_home(), "priv", "keypair.eterms"]).
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-spec read_keypair_from_file(FilePath) -> Result
|
||||||
|
when FilePath :: string(),
|
||||||
|
Result :: {ok, keypair()}
|
||||||
|
| {error, Reason :: term()}.
|
||||||
|
% @doc
|
||||||
|
% try to read keypair from file in `file:consult/1` format.
|
||||||
|
|
||||||
|
read_keypair_from_file(FilePath) ->
|
||||||
|
case file:consult(FilePath) of
|
||||||
|
{ok, [{public, PK}, {secret, SK}]} ->
|
||||||
|
{ok, #{public => PK, secret => SK}};
|
||||||
|
{ok, [{secret, SK}, {public, PK}]} ->
|
||||||
|
{ok, #{public => PK, secret => SK}};
|
||||||
|
{ok, Bad} ->
|
||||||
|
tell(warning, "read malformed keypair from file ~tp: ~tp", [FilePath, Bad]),
|
||||||
|
{error, bad_keypair};
|
||||||
|
Error ->
|
||||||
|
Error
|
||||||
|
end.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-spec write_keypair_to_file(FilePath, Keypair) -> Result
|
||||||
|
when FilePath :: string(),
|
||||||
|
Keypair :: keypair(),
|
||||||
|
Result :: ok
|
||||||
|
| {error, Reason :: term()}.
|
||||||
|
% @doc
|
||||||
|
% Write keypair to file as
|
||||||
|
%
|
||||||
|
% ```
|
||||||
|
% {public, <<...>>}.
|
||||||
|
% {secret, <<..>>}.
|
||||||
|
% ```
|
||||||
|
|
||||||
|
write_keypair_to_file(FP, KP) ->
|
||||||
|
file:write_file(FP, fmt_keypair(KP)).
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-spec fmt_pubkey_api(binary()) -> binary().
|
||||||
|
|
||||||
|
fmt_pubkey_api(Bin) ->
|
||||||
|
gmser_api_encoder:encode(account_pubkey, Bin).
|
||||||
|
|
||||||
|
|
||||||
|
-spec fmt_keypair(keypair()) -> iolist().
|
||||||
|
% @doc
|
||||||
|
% format keypair in `file:consult/1` format
|
||||||
|
|
||||||
|
fmt_keypair(#{public := PK, secret := SK}) ->
|
||||||
|
io_lib:format("{public, ~tp}.~n"
|
||||||
|
"{secret, ~tp}.~n",
|
||||||
|
[PK, SK]).
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-spec gen_keypair() -> keypair().
|
||||||
|
% @doc
|
||||||
|
% Generate a keypair
|
||||||
|
|
||||||
|
gen_keypair() ->
|
||||||
|
ecu_eddsa:sign_keypair().
|
||||||
154
gex_httpd/src/gh_sfc.erl
Normal file
154
gex_httpd/src/gh_sfc.erl
Normal file
@ -0,0 +1,154 @@
|
|||||||
|
% @doc static file cache
|
||||||
|
%
|
||||||
|
% polls priv/static for sheeeit
|
||||||
|
%
|
||||||
|
% Adapted from FEWD: https://git.qpq.swiss/pharpend/fewd/src/commit/9adbf67ebde14c7c1d8de70ec9b241e6d4ee6f45/src/fd_httpd_sfc.erl
|
||||||
|
-module(gh_sfc).
|
||||||
|
-behavior(gen_server).
|
||||||
|
|
||||||
|
-vsn("0.1.0").
|
||||||
|
-author("Peter Harpending <peterharpending@qpq.swiss>").
|
||||||
|
-copyright("2025-2026 QPQ AG").
|
||||||
|
-license("MIT").
|
||||||
|
|
||||||
|
-export_type([
|
||||||
|
entry/0,
|
||||||
|
maybe_entry/0
|
||||||
|
]).
|
||||||
|
|
||||||
|
%% caller context: actual api
|
||||||
|
-export([
|
||||||
|
base_path/0,
|
||||||
|
renew/0,
|
||||||
|
query/1
|
||||||
|
]).
|
||||||
|
|
||||||
|
%% caller context: startup
|
||||||
|
-export([start_link/0 ]).
|
||||||
|
%% gen_server callbacks (process context)
|
||||||
|
-export([init/1, handle_call/3, handle_cast/2, handle_info/2]).
|
||||||
|
|
||||||
|
-include("$zx_include/zx_logger.hrl").
|
||||||
|
|
||||||
|
|
||||||
|
-type cache() :: gh_sfc_cache:cache().
|
||||||
|
-type entry() :: gh_sfc_entry:entry().
|
||||||
|
-type maybe_entry() :: {found, entry()} | not_found.
|
||||||
|
|
||||||
|
|
||||||
|
-record(s, {base_path = base_path() :: file:filename(),
|
||||||
|
cache = gh_sfc_cache:new(base_path()) :: cache(),
|
||||||
|
auto_renew_ms = 1_000 :: pos_integer()}).
|
||||||
|
|
||||||
|
-type state() :: #s{}.
|
||||||
|
|
||||||
|
|
||||||
|
%%------------------------------------------------------------------
|
||||||
|
%% API (ACTUAL API / CALLER CONTEXT)
|
||||||
|
%%------------------------------------------------------------------
|
||||||
|
|
||||||
|
-spec base_path() -> file:filename().
|
||||||
|
|
||||||
|
base_path() ->
|
||||||
|
filename:join([zx:get_home(), "priv", "static"]).
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-spec renew() -> ok.
|
||||||
|
|
||||||
|
renew() ->
|
||||||
|
gen_server:cast(?MODULE, renew).
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-spec query(HttpPath) -> MaybeEntry
|
||||||
|
when HttpPath :: binary(),
|
||||||
|
MaybeEntry :: maybe_entry().
|
||||||
|
|
||||||
|
query(Path) ->
|
||||||
|
gen_server:call(?MODULE, {query, Path}).
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
%%------------------------------------------------------------------
|
||||||
|
%% API (STARTUP / CALLER CONTEXT)
|
||||||
|
%%------------------------------------------------------------------
|
||||||
|
|
||||||
|
-spec start_link() -> {ok, pid()} | ignore | {error, term()}.
|
||||||
|
|
||||||
|
start_link() ->
|
||||||
|
gen_server:start_link({local, ?MODULE}, ?MODULE, none, []).
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
%%------------------------------------------------------------------
|
||||||
|
%% API (GEN_SERVER CALLBACKS / PROCESS CONTEXT)
|
||||||
|
%%------------------------------------------------------------------
|
||||||
|
|
||||||
|
-spec init(Args) -> {ok, InitState}
|
||||||
|
when Args :: none,
|
||||||
|
InitState :: state().
|
||||||
|
|
||||||
|
init(none) ->
|
||||||
|
ok = tell("starting gh_sfc"),
|
||||||
|
InitState = #s{},
|
||||||
|
#s{auto_renew_ms = AutoRenewInterval} = InitState,
|
||||||
|
erlang:send_after(AutoRenewInterval, self(), auto_renew),
|
||||||
|
{ok, InitState}.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-spec handle_call(Request, From, State) -> MaybeReply
|
||||||
|
when Request :: term(),
|
||||||
|
From :: {pid(), Tag :: term()},
|
||||||
|
State :: state(),
|
||||||
|
MaybeReply :: {reply, Reply, NewState}
|
||||||
|
| {noreply, NewState},
|
||||||
|
Reply :: term(),
|
||||||
|
NewState :: State.
|
||||||
|
|
||||||
|
handle_call({query, Path}, _, State = #s{cache = Cache}) ->
|
||||||
|
Reply = gh_sfc_cache:query(Path, Cache),
|
||||||
|
{reply, Reply, State};
|
||||||
|
handle_call(Unexpected, From, State) ->
|
||||||
|
ok = log(warning, "~p ~p: unexpected call from ~p: ~p", [?MODULE, self(), From, Unexpected]),
|
||||||
|
{noreply, State}.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-spec handle_cast(Request, State) -> {noreply, NewState}
|
||||||
|
when Request :: term(),
|
||||||
|
State :: state(),
|
||||||
|
NewState :: State.
|
||||||
|
|
||||||
|
handle_cast(renew, State) ->
|
||||||
|
NewState = i_renew(State),
|
||||||
|
{noreply, NewState};
|
||||||
|
handle_cast(Unexpected, State) ->
|
||||||
|
ok = log(warning, "~p ~p: unexpected cast: ~p", [?MODULE, self(), Unexpected]),
|
||||||
|
{noreply, State}.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-spec handle_info(Info, State) -> {noreply, NewState}
|
||||||
|
when Info :: term(),
|
||||||
|
State :: state(),
|
||||||
|
NewState :: State.
|
||||||
|
|
||||||
|
handle_info(auto_renew, State = #s{auto_renew_ms = MS}) ->
|
||||||
|
erlang:send_after(MS, self(), auto_renew),
|
||||||
|
NewState = i_renew(State),
|
||||||
|
{noreply, NewState};
|
||||||
|
handle_info(Unexpected, State) ->
|
||||||
|
ok = log(warning, "~p ~p: unexpected info: ~p", [?MODULE, self(), Unexpected]),
|
||||||
|
{noreply, State}.
|
||||||
|
|
||||||
|
|
||||||
|
%%-------------------------------------------------------------------
|
||||||
|
%% INTERNALS
|
||||||
|
%%-------------------------------------------------------------------
|
||||||
|
|
||||||
|
i_renew(State = #s{base_path = BasePath}) ->
|
||||||
|
NewCache = gh_sfc_cache:new(BasePath),
|
||||||
|
NewState = State#s{cache = NewCache},
|
||||||
|
NewState.
|
||||||
90
gex_httpd/src/gh_sfc_cache.erl
Normal file
90
gex_httpd/src/gh_sfc_cache.erl
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
% @doc
|
||||||
|
% cache data management.
|
||||||
|
%
|
||||||
|
% Not pure code because logging and spam filesystem. But not a process
|
||||||
|
%
|
||||||
|
% Adapted from FEWD: https://git.qpq.swiss/pharpend/fewd/src/commit/9adbf67ebde14c7c1d8de70ec9b241e6d4ee6f45/src/fd_httpd_sfc_cache.erl
|
||||||
|
-module(gh_sfc_cache).
|
||||||
|
|
||||||
|
-vsn("0.1.0").
|
||||||
|
-author("Peter Harpending <peterharpending@qpq.swiss>").
|
||||||
|
-copyright("2025-2026 QPQ AG").
|
||||||
|
-license("MIT").
|
||||||
|
|
||||||
|
-export_type([
|
||||||
|
cache/0
|
||||||
|
]).
|
||||||
|
|
||||||
|
-export([
|
||||||
|
query/2,
|
||||||
|
new/0, new/1
|
||||||
|
]).
|
||||||
|
|
||||||
|
-include("$zx_include/zx_logger.hrl").
|
||||||
|
|
||||||
|
-type cache() :: #{HttpPath :: binary() := Entry :: gh_sfc_entry:entry()}.
|
||||||
|
|
||||||
|
|
||||||
|
-spec query(HttpPath, Cache) -> Result
|
||||||
|
when HttpPath :: binary(),
|
||||||
|
Cache :: cache(),
|
||||||
|
Result :: {found, Entry}
|
||||||
|
| not_found,
|
||||||
|
Entry :: gh_sfc_entry:entry().
|
||||||
|
|
||||||
|
query(HttpPath, Cache) ->
|
||||||
|
case maps:find(HttpPath, Cache) of
|
||||||
|
{ok, Entry} -> {found, Entry};
|
||||||
|
error -> not_found
|
||||||
|
end.
|
||||||
|
|
||||||
|
|
||||||
|
-spec new() -> cache().
|
||||||
|
new() -> #{}.
|
||||||
|
|
||||||
|
|
||||||
|
-spec new(BasePath) -> cache()
|
||||||
|
when BasePath :: file:filename().
|
||||||
|
% @doc
|
||||||
|
% if you give a file path it just takes the parent dir
|
||||||
|
%
|
||||||
|
% recursively crawls through file tree and picks
|
||||||
|
%
|
||||||
|
% IO errors will be logged but will result in cache misses
|
||||||
|
|
||||||
|
new(BasePath) ->
|
||||||
|
case filelib:is_file(BasePath) of
|
||||||
|
true -> new2(BasePath);
|
||||||
|
false ->
|
||||||
|
tell("~p:new(~p): no such file or directory, returning empty cache", [?MODULE, BasePath]),
|
||||||
|
#{}
|
||||||
|
end.
|
||||||
|
|
||||||
|
new2(BasePath) ->
|
||||||
|
BaseDir =
|
||||||
|
case filelib:is_dir(BasePath) of
|
||||||
|
true -> filename:absname(BasePath);
|
||||||
|
false -> filename:absname(filename:dirname(BasePath))
|
||||||
|
end,
|
||||||
|
BBaseDir = unicode:characters_to_binary(BaseDir),
|
||||||
|
HandlePath =
|
||||||
|
fun(AbsPath, AccCache) ->
|
||||||
|
BAbsPath = unicode:characters_to_binary(AbsPath),
|
||||||
|
HttpPath = remove_prefix(BBaseDir, BAbsPath),
|
||||||
|
NewCache =
|
||||||
|
case gh_sfc_entry:new(AbsPath) of
|
||||||
|
{found, Entry} -> maps:put(HttpPath, Entry, AccCache);
|
||||||
|
not_found -> AccCache
|
||||||
|
end,
|
||||||
|
NewCache
|
||||||
|
end,
|
||||||
|
filelib:fold_files(_dir = BaseDir,
|
||||||
|
_match = ".+",
|
||||||
|
_recursive = true,
|
||||||
|
_fun = HandlePath,
|
||||||
|
_init_acc = #{}).
|
||||||
|
|
||||||
|
remove_prefix(Prefix, From) ->
|
||||||
|
Size = byte_size(Prefix),
|
||||||
|
<<Prefix:Size/bytes, Rest/bytes>> = From,
|
||||||
|
Rest.
|
||||||
107
gex_httpd/src/gh_sfc_entry.erl
Normal file
107
gex_httpd/src/gh_sfc_entry.erl
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
% @doc non-servery functions for static file caching
|
||||||
|
%
|
||||||
|
% library code. Not pure code because logging and spam filesystem. but not a
|
||||||
|
% process
|
||||||
|
%
|
||||||
|
% Adapted from FEWD: https://git.qpq.swiss/pharpend/fewd/src/commit/9adbf67ebde14c7c1d8de70ec9b241e6d4ee6f45/src/fd_httpd_sfc_entry.erl
|
||||||
|
-module(gh_sfc_entry).
|
||||||
|
|
||||||
|
-vsn("0.1.0").
|
||||||
|
-author("Peter Harpending <peterharpending@qpq.swiss>").
|
||||||
|
-copyright("2025-2026 QPQ AG").
|
||||||
|
-license("MIT").
|
||||||
|
|
||||||
|
-export_type([
|
||||||
|
encoding/0,
|
||||||
|
entry/0
|
||||||
|
]).
|
||||||
|
|
||||||
|
-export([
|
||||||
|
%% constructor
|
||||||
|
new/1,
|
||||||
|
%% accessors
|
||||||
|
fs_path/1, last_modified/1, mime_type/1, encoding/1, contents/1
|
||||||
|
]).
|
||||||
|
|
||||||
|
-include("$zx_include/zx_logger.hrl").
|
||||||
|
|
||||||
|
%% types
|
||||||
|
|
||||||
|
% id = not compressed
|
||||||
|
-type encoding() :: none | gzip.
|
||||||
|
|
||||||
|
-record(e, {fs_path :: file:filename(),
|
||||||
|
last_modified :: file:date_time(),
|
||||||
|
mime_type :: string(),
|
||||||
|
encoding :: encoding(),
|
||||||
|
contents :: binary()}).
|
||||||
|
|
||||||
|
-opaque entry() :: #e{}.
|
||||||
|
|
||||||
|
%% accessors
|
||||||
|
|
||||||
|
fs_path(#e{fs_path = X}) -> X.
|
||||||
|
last_modified(#e{last_modified = X}) -> X.
|
||||||
|
mime_type(#e{mime_type = X}) -> X.
|
||||||
|
encoding(#e{encoding = X}) -> X.
|
||||||
|
contents(#e{contents = X}) -> X.
|
||||||
|
|
||||||
|
%% API
|
||||||
|
|
||||||
|
-spec new(Path) -> Result
|
||||||
|
when Path :: file:filename(),
|
||||||
|
Result :: {found, entry()}
|
||||||
|
| not_found.
|
||||||
|
% @doc
|
||||||
|
% absolute file path stored in resulting record
|
||||||
|
%
|
||||||
|
% returns not_found if ANY I/O error occurs during the process. will be logged
|
||||||
|
|
||||||
|
new(Path) ->
|
||||||
|
log(info, "~tp:new(~tp)", [?MODULE, Path]),
|
||||||
|
case file:read_file(Path) of
|
||||||
|
{ok, Binary} ->
|
||||||
|
{found, new2(Path, Binary)};
|
||||||
|
Error ->
|
||||||
|
tell("~tp:new(~tp): file read error: ~tp", [?MODULE, Path, Error]),
|
||||||
|
not_found
|
||||||
|
end.
|
||||||
|
|
||||||
|
%% can assume file exists
|
||||||
|
new2(FsPath, FileBytes) ->
|
||||||
|
LastModified = filelib:last_modified(FsPath),
|
||||||
|
{Encoding, MimeType} = mimetype_compress(FsPath),
|
||||||
|
Contents =
|
||||||
|
case Encoding of
|
||||||
|
none -> FileBytes;
|
||||||
|
gzip -> zlib:gzip(FileBytes)
|
||||||
|
end,
|
||||||
|
#e{fs_path = FsPath,
|
||||||
|
last_modified = LastModified,
|
||||||
|
mime_type = MimeType,
|
||||||
|
encoding = Encoding,
|
||||||
|
contents = Contents}.
|
||||||
|
|
||||||
|
mimetype_compress(FsPath) ->
|
||||||
|
case string:casefold(filename:extension(FsPath)) of
|
||||||
|
%% only including the ones i anticipate encountering
|
||||||
|
%% plaintext formats
|
||||||
|
".css" -> {gzip, "text/css"};
|
||||||
|
".htm" -> {gzip, "text/html"};
|
||||||
|
".html" -> {gzip, "text/html"};
|
||||||
|
".js" -> {gzip, "text/javascript"};
|
||||||
|
".json" -> {gzip, "application/json"};
|
||||||
|
".map" -> {gzip, "application/json"};
|
||||||
|
".md" -> {gzip, "text/markdown"};
|
||||||
|
".ts" -> {gzip, "text/x-typescript"};
|
||||||
|
".txt" -> {gzip, "text/plain"};
|
||||||
|
%% binary formats
|
||||||
|
".gif" -> {none, "image/gif"};
|
||||||
|
".jpg" -> {none, "image/jpeg"};
|
||||||
|
".jpeg" -> {none, "image/jpeg"};
|
||||||
|
".mp4" -> {none, "video/mp4"};
|
||||||
|
".png" -> {none, "image/png"};
|
||||||
|
".webm" -> {none, "video/webm"};
|
||||||
|
".webp" -> {none, "image/webp"};
|
||||||
|
_ -> {none, "application/octet-stream"}
|
||||||
|
end.
|
||||||
@ -12,11 +12,12 @@
|
|||||||
%%% @end
|
%%% @end
|
||||||
|
|
||||||
-module(gh_sup).
|
-module(gh_sup).
|
||||||
-vsn("0.1.0").
|
|
||||||
-behaviour(supervisor).
|
-behaviour(supervisor).
|
||||||
-author("Peter Harpending <peterharpending@qpq.swiss>").
|
|
||||||
-copyright("Peter Harpending <peterharpending@qpq.swiss>").
|
|
||||||
|
|
||||||
|
-vsn("0.1.0").
|
||||||
|
-author("Peter Harpending <peterharpending@qpq.swiss>").
|
||||||
|
-copyright("2025-2026 QPQ AG").
|
||||||
|
-license("MIT").
|
||||||
|
|
||||||
-export([start_link/0]).
|
-export([start_link/0]).
|
||||||
-export([init/1]).
|
-export([init/1]).
|
||||||
@ -36,11 +37,17 @@ start_link() ->
|
|||||||
|
|
||||||
init([]) ->
|
init([]) ->
|
||||||
RestartStrategy = {one_for_one, 1, 60},
|
RestartStrategy = {one_for_one, 1, 60},
|
||||||
|
StaticFileCache = {gh_sfc,
|
||||||
|
{gh_sfc, start_link, []},
|
||||||
|
permanent,
|
||||||
|
5000,
|
||||||
|
worker,
|
||||||
|
[gh_sfc]},
|
||||||
Clients = {gh_clients,
|
Clients = {gh_clients,
|
||||||
{gh_clients, start_link, []},
|
{gh_clients, start_link, []},
|
||||||
permanent,
|
permanent,
|
||||||
5000,
|
5000,
|
||||||
supervisor,
|
supervisor,
|
||||||
[gh_clients]},
|
[gh_clients]},
|
||||||
Children = [Clients],
|
Children = [StaticFileCache, Clients],
|
||||||
{ok, {RestartStrategy, Children}}.
|
{ok, {RestartStrategy, Children}}.
|
||||||
|
|||||||
@ -20,9 +20,9 @@
|
|||||||
{key_name,none}.
|
{key_name,none}.
|
||||||
{a_email,"peterharpending@qpq.swiss"}.
|
{a_email,"peterharpending@qpq.swiss"}.
|
||||||
{c_email,"peterharpending@qpq.swiss"}.
|
{c_email,"peterharpending@qpq.swiss"}.
|
||||||
{copyright,"Peter Harpending"}.
|
{copyright,"2025-2026, QPQ AG"}.
|
||||||
{file_exts,[]}.
|
{file_exts,[]}.
|
||||||
{license,skip}.
|
{license,mit}.
|
||||||
{repo_url,"https://git.qpq.swiss/QPQ-AG/gex"}.
|
{repo_url,"https://git.qpq.swiss/QPQ-AG/gex"}.
|
||||||
{tags,[]}.
|
{tags,[]}.
|
||||||
{ws_url,"https://git.qpq.swiss/QPQ-AG/gex"}.
|
{ws_url,"https://git.qpq.swiss/QPQ-AG/gex"}.
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user