diff --git a/gex_httpd/README.md b/gex_httpd/README.md new file mode 100644 index 0000000..3c0acd1 --- /dev/null +++ b/gex_httpd/README.md @@ -0,0 +1,14 @@ +`gex_httpd`: Gajumaru Exchange HTTP Daemon +===================================================================== + +GOAL STACK +-------------------------------------------------------------------- + +- write out call paths for `gh_sfc` + + +GOAL QUEUE +-------------------------------------------------------------------- + +- mit license +- copyright/author bullshit in each module diff --git a/gex_httpd/src/gh_sfc.erl b/gex_httpd/src/gh_sfc.erl index 288bb6d..b664347 100644 --- a/gex_httpd/src/gh_sfc.erl +++ b/gex_httpd/src/gh_sfc.erl @@ -1,26 +1,42 @@ % @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). +-vsn("0.1.0"). -behavior(gen_server). -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]). - +-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"). --record(s, {}). + +-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{}. @@ -28,6 +44,27 @@ %% 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}). + %%------------------------------------------------------------------ @@ -51,7 +88,10 @@ start_link() -> init(none) -> ok = tell("starting gh_sfc"), - {ok, #s{}}. + InitState = #s{}, + #s{auto_renew_ms = AutoRenewInterval} = InitState, + erlang:send_after(AutoRenewInterval, self(), auto_renew), + {ok, InitState}. @@ -64,6 +104,9 @@ init(none) -> 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}. @@ -75,6 +118,9 @@ handle_call(Unexpected, From, State) -> 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}. @@ -86,6 +132,20 @@ handle_cast(Unexpected, State) -> 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. diff --git a/gex_httpd/src/gh_sfc_cache.erl b/gex_httpd/src/gh_sfc_cache.erl new file mode 100644 index 0000000..b57ef3e --- /dev/null +++ b/gex_httpd/src/gh_sfc_cache.erl @@ -0,0 +1,86 @@ +% @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"). + +-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), + <> = From, + Rest. diff --git a/gex_httpd/src/gh_sfc_entry.erl b/gex_httpd/src/gh_sfc_entry.erl new file mode 100644 index 0000000..232bb95 --- /dev/null +++ b/gex_httpd/src/gh_sfc_entry.erl @@ -0,0 +1,103 @@ +% @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"). + +-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. diff --git a/gex_httpd/src/gh_sup.erl b/gex_httpd/src/gh_sup.erl index ca10a8b..5ac7d2e 100644 --- a/gex_httpd/src/gh_sup.erl +++ b/gex_httpd/src/gh_sup.erl @@ -15,7 +15,7 @@ -vsn("0.1.0"). -behaviour(supervisor). -author("Peter Harpending "). --copyright("2025-2026 QPQ AG"). +-copyright("2025-2026, QPQ AG"). -export([start_link/0]).