Compare commits

..

10 Commits

Author SHA1 Message Date
Peter Harpending
51c081fb55 bike shedding 2026-01-12 18:40:25 -08:00
Peter Harpending
2cfa90beb1 add mit license 2026-01-12 18:34:39 -08:00
Peter Harpending
f73756c15a add a readme 2026-01-12 18:33:48 -08:00
Peter Harpending
d26cb75331 file cache seems done 2026-01-12 18:31:31 -08:00
Peter Harpending
46aacfb621 file cache seems to work 2026-01-12 18:25:34 -08:00
Peter Harpending
6bfd19e027 painting the bike shed 2026-01-12 17:52:28 -08:00
Peter Harpending
c270e18ed0 gh_sfc compiles, code paths seem done 2026-01-12 17:32:00 -08:00
Peter Harpending
46b93158db static file cache boots up 2026-01-12 16:56:39 -08:00
Peter Harpending
6ba256f016 transitioning this from a tutorial project to a real project 2026-01-12 16:09:37 -08:00
Peter Harpending
7299dbc9f1 deploy hello world contract to testnet 2025-09-26 14:06:54 -07:00
26 changed files with 740 additions and 1317 deletions

19
LICENSE Normal file
View 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.

1166
README.md

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 219 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

View File

@ -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.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 219 KiB

View File

@ -1,3 +1,4 @@
priv/keypair.eterms
.eunit
deps
*.o

26
gex_httpd/README.md Normal file
View 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

View File

@ -5,5 +5,6 @@
{applications,[stdlib,kernel]},
{vsn,"0.1.0"},
{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,[]}}]}.

View File

@ -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>

View File

@ -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>

View 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"

View File

@ -3,20 +3,26 @@
%%% @end
-module(gex_httpd).
-vsn("0.1.0").
-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
-export([listen/1, ignore/0]).
-export([start/0]).
%% erlang expects us to export these functions
-export([start/2, stop/1]).
-include("$zx_include/zx_logger.hrl").
%------------------------------------------------------
% API
%------------------------------------------------------
-spec listen(PortNum) -> Result
when PortNum :: inet:port_num(),
@ -38,27 +44,6 @@ 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()}.
%% @private
@ -68,27 +53,25 @@ start() ->
start(normal, _Args) ->
Result = gh_sup:start_link(),
% auto-listen to port 8000
ok = hz(),
% auto-listen to port 8000
ok = listen(8000),
Result.
hz() ->
ok = application:ensure_started(hakuzaru),
ok = hz:chain_nodes([testnet_node()]),
ok = zx:tell("hz status: ~tp", [hz:status()]),
%TestnetIP = {84, 46, 242, 9},
% 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.
testnet_ip() ->
{84, 46, 242, 9}.
testnet_port() ->
3013.
testnet_node() ->
{testnet_ip(), testnet_port()}.
-spec stop(term()) -> ok.
%% @private

View File

@ -14,9 +14,11 @@
%%% @end
-module(gh_client).
-vsn("0.1.0").
-author("Peter Harpending <peterharpending@qpq.swiss>").
-copyright("Peter Harpending <peterharpending@qpq.swiss>").
-copyright("2025-2026 QPQ AG").
-license("MIT").
-export([start/1]).
@ -24,6 +26,7 @@
-export([system_continue/3, system_terminate/4,
system_get_state/1, system_replace_state/2]).
-include("$zx_include/zx_logger.hrl").
-include("http.hrl").
@ -85,7 +88,7 @@ start_link(ListenSocket) ->
%% call to listen/3.
init(Parent, ListenSocket) ->
ok = io:format("~p Listening.~n", [self()]),
ok = tell("~p Listening.~n", [self()]),
Debug = sys:debug_options([]),
ok = proc_lib:init_ack(Parent, {ok, self()}),
listen(Parent, Debug, ListenSocket).
@ -109,12 +112,12 @@ listen(Parent, Debug, ListenSocket) ->
{ok, Socket} ->
{ok, _} = start(ListenSocket),
{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(),
State = #s{socket = Socket},
loop(Parent, Debug, State);
{error, closed} ->
ok = io:format("~p Retiring: Listen socket closed.~n", [self()]),
ok = log("~p Retiring: Listen socket closed.~n", [self()]),
exit(normal)
end.
@ -131,7 +134,6 @@ 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]),
%% Received exists because web browsers usually use the same
%% 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
case qhl:parse(Socket, Message2) of
{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},
loop(Parent, Debug, NewState);
{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),
exit(normal)
end;
{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);
{system, From, Request} ->
sys:handle_system_msg(Request, From, Parent, ?MODULE, Debug, State);
Unexpected ->
ok = io:format("~p Unexpected message: ~tp", [self(), Unexpected]),
ok = tell("~p ~p: Unexpected message: ~tp", [?MODULE, self(), Unexpected]),
loop(Parent, Debug, State)
end.
@ -230,26 +238,54 @@ system_replace_state(StateFun, State) ->
%% 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)
handle_request(Socket, #request{method = get, path = Path}) ->
% future-proofing for hardcoded paths
case Path of
_ -> handle_static(Socket, Path)
end;
handle_request(Socket, _) ->
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) ->
HtmlPath = filename:join([zx:get_home(), "priv", "404.html"]),
{ok, ResponseBody} = file:read_file(HtmlPath),
ResponseBody = bdy_404(),
Headers = [{"content-type", "text/html"}],
Response = #response{headers = Headers,
code = 404,
@ -257,8 +293,7 @@ http_err(Socket, 404) ->
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),
ResponseBody = bdy_500(),
Headers = [{"content-type", "text/html"}],
Response = #response{headers = Headers,
code = 500,
@ -266,11 +301,39 @@ http_err(Socket, _) ->
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}) ->
Slogan = slogan(Code),
ContentLength = byte_size(Body),
DefaultHeaders = [{"date", qhl:ridiculous_web_date()},
{"content-length", integer_to_list(ContentLength)}],
BodyBytes = iolist_to_binary(Body),
ContentLength = byte_size(BodyBytes),
DefaultHeaders = [{"Server", "gex_httpd 0.1.0"},
{"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}).

View File

@ -10,10 +10,12 @@
%%% @end
-module(gh_client_man).
-vsn("0.1.0").
-behavior(gen_server).
-vsn("0.1.0").
-author("Peter Harpending <peterharpending@qpq.swiss>").
-copyright("Peter Harpending <peterharpending@qpq.swiss>").
-copyright("2025-2026 QPQ AG").
-license("MIT").
-export([listen/1, ignore/0]).
@ -23,6 +25,8 @@
code_change/3, terminate/2]).
-include("$zx_include/zx_logger.hrl").
%%% Type and Record Definitions
@ -93,7 +97,7 @@ start_link() ->
%% preparatory work necessary for proper function.
init(none) ->
ok = io:format("Starting.~n"),
ok = tell("~p ~p: Starting.~n", [?MODULE, self()]),
State = #s{},
{ok, State}.
@ -119,7 +123,7 @@ handle_call({listen, PortNum}, _, State) ->
{Response, NewState} = do_listen(PortNum, State),
{reply, Response, NewState};
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}.
@ -138,7 +142,7 @@ handle_cast(ignore, State) ->
NewState = do_ignore(State),
{noreply, NewState};
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}.
@ -154,7 +158,7 @@ handle_info({'DOWN', Mon, process, Pid, Reason}, State) ->
NewState = handle_down(Mon, Pid, Reason, State),
{noreply, NewState};
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}.
@ -206,7 +210,6 @@ terminate(_, _) ->
do_listen(PortNum, State = #s{port_num = none}) ->
SocketOptions =
[inet6,
{packet, line},
{active, once},
{mode, binary},
{keepalive, true},
@ -215,7 +218,7 @@ do_listen(PortNum, State = #s{port_num = none}) ->
{ok, _} = gh_client:start(Listener),
{ok, State#s{port_num = PortNum, listener = Listener}};
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}.
@ -241,7 +244,7 @@ do_enroll(Pid, State = #s{clients = Clients}) ->
case lists:member(Pid, Clients) of
false ->
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]};
true ->
State
@ -268,6 +271,6 @@ handle_down(Mon, Pid, Reason, State = #s{clients = Clients}) ->
State#s{clients = NewClients};
false ->
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
end.

View File

@ -14,12 +14,12 @@
%%% @end
-module(gh_client_sup).
-vsn("0.1.0").
-behaviour(supervisor).
-vsn("0.1.0").
-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_link/0]).

View File

@ -9,10 +9,12 @@
%%% @end
-module(gh_clients).
-vsn("0.1.0").
-behavior(supervisor).
-vsn("0.1.0").
-author("Peter Harpending <peterharpending@qpq.swiss>").
-copyright("Peter Harpending <peterharpending@qpq.swiss>").
-copyright("2025-2026 QPQ AG").
-license("MIT").
-export([start_link/0]).

168
gex_httpd/src/gh_ct.erl Normal file
View 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
View 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.

View 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.

View 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.

View File

@ -12,11 +12,12 @@
%%% @end
-module(gh_sup).
-vsn("0.1.0").
-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([init/1]).
@ -36,11 +37,17 @@ start_link() ->
init([]) ->
RestartStrategy = {one_for_one, 1, 60},
StaticFileCache = {gh_sfc,
{gh_sfc, start_link, []},
permanent,
5000,
worker,
[gh_sfc]},
Clients = {gh_clients,
{gh_clients, start_link, []},
permanent,
5000,
supervisor,
[gh_clients]},
Children = [Clients],
Children = [StaticFileCache, Clients],
{ok, {RestartStrategy, Children}}.

View File

@ -20,9 +20,9 @@
{key_name,none}.
{a_email,"peterharpending@qpq.swiss"}.
{c_email,"peterharpending@qpq.swiss"}.
{copyright,"Peter Harpending"}.
{copyright,"2025-2026, QPQ AG"}.
{file_exts,[]}.
{license,skip}.
{license,mit}.
{repo_url,"https://git.qpq.swiss/QPQ-AG/gex"}.
{tags,[]}.
{ws_url,"https://git.qpq.swiss/QPQ-AG/gex"}.