diff options
author | Jordan Bracco <href@random.sh> | 2022-04-07 23:54:23 +0000 |
---|---|---|
committer | Jordan Bracco <href@random.sh> | 2022-04-07 23:54:23 +0000 |
commit | 6d7887c51ba7664688bc568aa0c8538da0e64e7b (patch) | |
tree | 452ded9651d17bdd3fe941f15dfec2325f008db9 /apps/dreki_web/src |
guess it was time for an initial commit
Diffstat (limited to 'apps/dreki_web/src')
-rw-r--r-- | apps/dreki_web/src/dreki_web.app.src | 20 | ||||
-rw-r--r-- | apps/dreki_web/src/dreki_web.erl | 71 | ||||
-rw-r--r-- | apps/dreki_web/src/dreki_web_admin_tasks.erl | 30 | ||||
-rw-r--r-- | apps/dreki_web/src/dreki_web_admin_world.erl | 28 | ||||
-rw-r--r-- | apps/dreki_web/src/dreki_web_app.erl | 68 | ||||
-rw-r--r-- | apps/dreki_web/src/dreki_web_auth.erl | 45 | ||||
-rw-r--r-- | apps/dreki_web/src/dreki_web_error.erl | 20 | ||||
-rw-r--r-- | apps/dreki_web/src/dreki_web_index.erl | 9 | ||||
-rw-r--r-- | apps/dreki_web/src/dreki_web_sup.erl | 35 | ||||
-rw-r--r-- | apps/dreki_web/src/dreki_web_task.erl | 21 | ||||
-rw-r--r-- | apps/dreki_web/src/dreki_web_ui.erl | 43 | ||||
-rw-r--r-- | apps/dreki_web/src/dreki_web_ui_error.erl | 14 | ||||
-rw-r--r-- | apps/dreki_web/src/dreki_web_ui_index.erl | 31 | ||||
-rw-r--r-- | apps/dreki_web/src/dreki_web_ui_json_form.erl | 127 | ||||
-rw-r--r-- | apps/dreki_web/src/dreki_web_ui_node.erl | 23 | ||||
-rw-r--r-- | apps/dreki_web/src/dreki_web_ui_stores.erl | 126 | ||||
-rw-r--r-- | apps/dreki_web/src/dreki_web_ui_task.erl | 20 | ||||
-rw-r--r-- | apps/dreki_web/src/dreki_web_ui_tasks.erl | 14 |
18 files changed, 745 insertions, 0 deletions
diff --git a/apps/dreki_web/src/dreki_web.app.src b/apps/dreki_web/src/dreki_web.app.src new file mode 100644 index 0000000..cf731fc --- /dev/null +++ b/apps/dreki_web/src/dreki_web.app.src @@ -0,0 +1,20 @@ +{application, dreki_web, + [{description, "An OTP application"}, + {vsn, "0.1.0"}, + {registered, []}, + {mod, {dreki_web_app, []}}, + {applications, + [kernel, + stdlib, + erlydtl, + cowboy, + trails, + cowboy_telemetry, + opentelemetry_cowboy + ]}, + {env,[]}, + {modules, []}, + + {licenses, ["Apache 2.0"]}, + {links, []} + ]}. diff --git a/apps/dreki_web/src/dreki_web.erl b/apps/dreki_web/src/dreki_web.erl new file mode 100644 index 0000000..f9e01e6 --- /dev/null +++ b/apps/dreki_web/src/dreki_web.erl @@ -0,0 +1,71 @@ +-module(dreki_web). + +-export([reply/5, reply_json/3, reply_json/4, temporary_redirect/2, req_param/2, identity_name/1]). +-export([content_types_accepted/2, content_types_provided/2]). +-export([detect_web_mimetype/1]). + +identity_name(#{identity := Identity}) -> + identity_name(Identity); +identity_name(#{<<"identity">> := Identity}) -> + identity_name(Identity); +identity_name(#{<<"traits">> := #{<<"name">> := #{<<"first">> := F, <<"last">> := L}}}) when is_binary(F), is_binary(L) -> + [F, " ", L]; +identity_name(#{<<"traits">> := #{<<"name">> := N}}) when is_binary(N) -> + N; +identity_name(#{<<"traits">> := #{<<"username">> := U}}) when is_binary(U) -> + U; +identity_name(#{<<"traits">> := #{<<"email">> := E}}) when is_binary(E) -> + E; +identity_name(#{<<"id">> := Id}) -> + Id. + +reply(Req, Code, Json, Headers, json) -> + reply_json(Req, Code, Json, Headers); +reply(Req, Code, Json, Headers, yaml) -> + reply_yaml(Req, Code, Json, Headers). + +reply_json(Req, Code, Json) -> + reply_json(Req, Code, Json, #{}). +reply_json(Req, Code, Json, Headers0) when is_binary(Json) -> + Headers = maps:put(<<"content-type">>, <<"application/json">>, Headers0), + cowboy_req:reply(Code, Headers, Json, Req); +reply_json(Req, Code, Json, Headers0) -> + reply_json(Req, Code, jsone:encode(Json), Headers0). + +reply_yaml(Req, Code, Yaml) -> + reply_yaml(Req, Code, Yaml, #{}). +reply_yaml(Req, Code, Yaml, Headers0) when is_binary(Yaml) -> + Headers = maps:put(<<"content-type">>, <<"application/yaml">>, Headers0), + cowboy_req:reply(Code, Headers, Yaml, Req); +reply_yaml(Req, Code, Yaml, Headers0) -> + reply_yaml(Req, Code, fast_yaml:encode(Yaml), Headers0). + +temporary_redirect(Req0, Url) -> + cowboy_req:reply(307, #{<<"location">> => Url}, Req0). + +req_param(Req, Param) -> + Qs = cowboy_req:parse_qs(Req), + case lists:keyfind(Param, 1, Qs) of + {_, Value} -> + {ok, Value}; + _ -> + {error, {missing_param, Param}} + end. + +content_types_accepted(Req, State) -> + {[ + {{ <<"application">>, <<"json">>, '*'}, from_json}, + {{ <<"application">>, <<"yaml">>, '*'}, from_yaml}, + {{ <<"multipart">>, <<"form-data">>, '*'}, from_form} + ], Req, State}. + +content_types_provided(Req, State) -> + {[{{ <<"application">>, <<"json">>, '*'}, to_json}], Req, State}. + +detect_web_mimetype(Path) when is_binary(Path) -> + detect_web_mimetype(lists:reverse(binary_to_list(Path)), Path). + +detect_web_mimetype([$m, $s, $a, $w, $. | _], _) -> + {<<"application">>, <<"wasm">>, []}; +detect_web_mimetype(P, Path) -> + cow_mimetypes:web(Path). diff --git a/apps/dreki_web/src/dreki_web_admin_tasks.erl b/apps/dreki_web/src/dreki_web_admin_tasks.erl new file mode 100644 index 0000000..adc2689 --- /dev/null +++ b/apps/dreki_web/src/dreki_web_admin_tasks.erl @@ -0,0 +1,30 @@ +-module(dreki_web_admin_tasks). +-behaviour(cowboy_rest). +-export([init/2]). +-export([allowed_methods/2]). +-export([content_types_accepted/2]). +-export([content_types_provided/2]). + +init(Req, State) -> + {cowboy_rest, Req, State}. + +allowed_methods(Req, State) -> + {[<<"GET">>, <<"HEAD">>, <<"OPTIONS">>, <<"POST">>], Req, State}. + +content_types_accepted(Req, State) -> + dreki_web:content_types_accepted(Req, State). + +content_types_provided(Req, State) -> + dreki_web:content_types_provided(Req, State). + +list(Req, State, Format) -> + {ok, Db} = dreki_tasks:open(), + {ok, Tasks} = dreki_tasks:all(Db), + MTasks = lists:foldl(fun (T, Acc) -> [dreki_task:to_map(T) | Acc] end, [], Tasks), + dreki_web:reply(Req, 200, #{error => false, tasks => MTasks}, [], Format). + +to_json(Req, State) -> + list(Req, State, json). + +to_yaml(Req, State) -> + list(Req, State, yaml). diff --git a/apps/dreki_web/src/dreki_web_admin_world.erl b/apps/dreki_web/src/dreki_web_admin_world.erl new file mode 100644 index 0000000..1ceaaee --- /dev/null +++ b/apps/dreki_web/src/dreki_web_admin_world.erl @@ -0,0 +1,28 @@ +-module(dreki_web_admin_world). +-behaviour(cowboy_handler). +-export([init/2]). + +init(Req, index) -> + Json = #{<<"data">> => #{<<"service">> => <<"dreki">>}}, + {ok, dreki_web:reply_json(Req, 200, Json), undefined}; + +init(Req, graph) -> + World = dreki_world_dns:as_map(), + Json = #{<<"data">> => World}, + {ok, dreki_web:reply_json(Req, 200, Json), undefined}; + +init(Req, {graph, dot}) -> + World = dreki_world_dns:as_map(), + Vertices = [dot_format_vertex(N) || N <- maps:get(vertices, World)], + {ok, Dot} = world_graph_dot_dtl:render([{vertices, Vertices}, {edges, maps:get(edges, World)}]), + cowboy_req:reply(200, #{<<"content-type">> => <<"text/vnd.graphviz">>}, Dot, Req); + +init(Req, _) -> + dreki_web_error:init(Req, #{code => 404, status => "Not found"}). + +dot_format_vertex(V = #{type := node}) -> + V#{shape => <<"box">>, color => <<"#c2410c">>, class => <<"dreki-world-graph-node">>}; +dot_format_vertex(V = #{type := root}) -> + V#{shape => <<"polygon">>, color => <<"#a16207">>, class => <<"dreki-world-graph-root">>}; +dot_format_vertex(V = #{type := region}) -> + V#{shape => <<"egg">>, color => <<"#4d7c0f">>, class => <<"dreki-world-graph-region">>}. diff --git a/apps/dreki_web/src/dreki_web_app.erl b/apps/dreki_web/src/dreki_web_app.erl new file mode 100644 index 0000000..5b6454e --- /dev/null +++ b/apps/dreki_web/src/dreki_web_app.erl @@ -0,0 +1,68 @@ +%%%------------------------------------------------------------------- +%% @doc dreki_web public API +%% @end +%%%------------------------------------------------------------------- + +-module(dreki_web_app). + +-behaviour(application). + +-export([start/2, stop/1]). + +start(_StartType, _StartArgs) -> + Config = application:get_all_env(dreki_web), + Transport = proplists:get_value(transport, Config), + CowboyEnv = #{ + middlewares => [dreki_web_auth, cowboy_router, cowboy_handler], + stream_handlers => [cowboy_telemetry_h, cowboy_stream_h], + env => #{ + dispatch => routes() + } + }, + {ok, _} = cowboy:start_clear(dreki_web_listener, Transport, CowboyEnv), + opentelemetry_cowboy:setup(), + IP = proplists:get_value(ip, Transport), + Port = proplists:get_value(port, Transport), + logger:notice("dreki_web listening on ~p:~p", [IP, Port]), + dreki_web_sup:start_link(). + +stop(_State) -> + ok. + +%% internal functions + +routes() -> + Trails = [ + {"/", dreki_web_index, undefined}, + {"/static/[...]", cowboy_static, + {priv_dir, dreki_web, "static", [{mimetypes, dreki_web, detect_web_mimetype}]}}, + + %% API + {"/api/tasks/:id", dreki_web_task, undefined}, + + %% Admin API + {"/api/admin/world", dreki_web_admin_world, index}, + {"/api/admin/world/graph", dreki_web_admin_world, graph}, + {"/api/admin/world/graph.dot", dreki_web_admin_world, {graph, dot}}, + {"/api/admin/tasks", dreki_web_admin_tasks, undefined}, + {"/api/admin/tasks/:id", dreki_web_admin_task, undefined}, + + %% Admin UI + {"/admin", dreki_web_ui_index, undefined}, + {"/admin/nodes/:id", dreki_web_ui_node, undefined}, + {"/admin/tasks", dreki_web_ui_tasks, undefined}, + {"/admin/tasks/:id", dreki_web_ui_task, undefined}, + + %%{"/admin/stores", dreki_web_ui_stores, undefined}, + %%{"/admin/:location/stores", dreki_web_ui_stores, undefined}, + {"/admin/:location/:namespace", dreki_web_ui_stores, undefined}, + {"/admin/:location/:namespace/:directory", dreki_web_ui_stores, undefined}, + {"/admin/:location/:namespace/:directory/_/:action", dreki_web_ui_stores, action}, + {"/admin/:location/:namespace/:directory/:id", dreki_web_ui_stores, undefined}, + + {"/admin/[...]", dreki_web_ui_error, #{code => 404, status => <<"Not found">>}}, + + %% 404 Catch all + {'_', dreki_web_error, #{code => 404, status => <<"Not found">>}} + ], + trails:single_host_compile(Trails). diff --git a/apps/dreki_web/src/dreki_web_auth.erl b/apps/dreki_web/src/dreki_web_auth.erl new file mode 100644 index 0000000..48b0609 --- /dev/null +++ b/apps/dreki_web/src/dreki_web_auth.erl @@ -0,0 +1,45 @@ +-module(dreki_web_auth). +-behaviour(cowboy_middleware). +-export([execute/2]). + +execute(Req0, Env) -> + Header = cowboy_req:header(<<"authorization">>, Req0), + Cookie = cowboy_req:header(<<"cookie">>, Req0), + case {Header, Cookie} of + {<<"Basic ", Basic/binary>>, _} -> basic_oauth2_client_credentials(Basic, Req0, Env); + {_, Cookie} when is_binary(Cookie) -> cookie(Cookie, Req0, Env); + _ -> error(Req0, 401, <<"Unauthorized">>, <<"Unauthorized">>, Env) + end. + +basic_oauth2_client_credentials(Base64, Req0, Env) -> + Binary = base64:decode(Base64), + [User, Pass] = binary:split(Binary, <<":">>), + oauth2_client_credentials(User, Pass, Req0, Env). + +oauth2_client_credentials(User, Pass, Req0, Env) -> + logger:debug("Trying with oauth2 ~p ~p", [User, Pass]), + Url = [ory_hydra:url(), "/oauth2/token"], + case oauth2c:retrieve_access_token(<<"client_credentials">>, Url, User, Pass) of + {ok, Headers, Client} -> + logger:debug("Headers ~p Client ~p", [Headers, Client]), + identity(Req0, Headers, Env) + end. + +cookie(Cookie, Req0, Env) -> + case ory_kratos:whoami(Cookie) of + {ok, Session = #{<<"active">> := true, <<"identity">> := Identity}} -> + identity(Req0, Session, Env); + {error, #{<<"code">> := Code, <<"status">> := Status, <<"message">> := Msg}} -> + error(Req0, Code, Status, Msg, Env) + end. + +identity(Req0, Identity, Env0) -> + IdentityId = maps:get(<<"id">>, Identity), + Env1 = maps:put(<<"identity">>, Identity, Env0), + Env2 = maps:put(<<"identity_id">>, IdentityId, Env1), + {ok, Req0#{identity => Identity, identity_id => IdentityId}, Env2}. + +error(Req0, Code, Status, Msg, _Env) -> + Json = #{<<"error">> => #{<<"code">> => Code, <<"status">> => Status, <<"message">> => Msg}}, + Req = dreki_web:reply_json(Req0, Code, Json), + {stop, Req}. diff --git a/apps/dreki_web/src/dreki_web_error.erl b/apps/dreki_web/src/dreki_web_error.erl new file mode 100644 index 0000000..e300582 --- /dev/null +++ b/apps/dreki_web/src/dreki_web_error.erl @@ -0,0 +1,20 @@ +-module(dreki_web_error). +-behaviour(cowboy_handler). +-export([init/2]). + +init(Req, not_found) -> + reply(Req, 404, <<"Not Found">>, undefined); +init(Req, State = #{code := Code, status := Status}) -> + reply(Req, Code, Status, maps:get(message, State, undefined)); +init(Req, oauth2) -> + {ok, ErrorDescription} = dreki_web:req_param(Req, <<"error_description">>), + reply(Req, 500, <<"Error">>, ErrorDescription); +init(Req = #{method := <<"GET">>}, _) -> + {ok, ErrorId} = dreki_web:req_param(Req, <<"id">>), + {ok, #{<<"error">> := #{<<"status">> := Status, <<"code">> := Code, <<"message">> := Msg}}} = ory_kratos:error(ErrorId), + reply(Req, Code, Status, Msg). + +reply(Req0, Code, Status, Msg) -> + Json = #{<<"error">> => #{<<"code">> => Code, <<"status">> => Status, <<"message">> => Msg}}, + Req = dreki_web:reply_json(Req0, Code, Json), + {ok, Req, undefined}. diff --git a/apps/dreki_web/src/dreki_web_index.erl b/apps/dreki_web/src/dreki_web_index.erl new file mode 100644 index 0000000..2ed8a38 --- /dev/null +++ b/apps/dreki_web/src/dreki_web_index.erl @@ -0,0 +1,9 @@ +-module(dreki_web_index). +-behaviour(cowboy_handler). +-export([init/2]). + +init(Req = #{method := <<"GET">>}, _) -> + Json = #{<<"error">> => false, <<"service">> => <<"dreki">>}, + {ok, dreki_web:reply_json(Req, 200, Json), undefined}; +init(Req, _) -> + dreki_web_error:init(Req, #{code => 400, status => "Bad request"}). diff --git a/apps/dreki_web/src/dreki_web_sup.erl b/apps/dreki_web/src/dreki_web_sup.erl new file mode 100644 index 0000000..8414aa7 --- /dev/null +++ b/apps/dreki_web/src/dreki_web_sup.erl @@ -0,0 +1,35 @@ +%%%------------------------------------------------------------------- +%% @doc dreki_web top level supervisor. +%% @end +%%%------------------------------------------------------------------- + +-module(dreki_web_sup). + +-behaviour(supervisor). + +-export([start_link/0]). + +-export([init/1]). + +-define(SERVER, ?MODULE). + +start_link() -> + supervisor:start_link({local, ?SERVER}, ?MODULE, []). + +%% sup_flags() = #{strategy => strategy(), % optional +%% intensity => non_neg_integer(), % optional +%% period => pos_integer()} % optional +%% child_spec() = #{id => child_id(), % mandatory +%% start => mfargs(), % mandatory +%% restart => restart(), % optional +%% shutdown => shutdown(), % optional +%% type => worker(), % optional +%% modules => modules()} % optional +init([]) -> + SupFlags = #{strategy => one_for_all, + intensity => 0, + period => 1}, + ChildSpecs = [], + {ok, {SupFlags, ChildSpecs}}. + +%% internal functions diff --git a/apps/dreki_web/src/dreki_web_task.erl b/apps/dreki_web/src/dreki_web_task.erl new file mode 100644 index 0000000..fdcf9cd --- /dev/null +++ b/apps/dreki_web/src/dreki_web_task.erl @@ -0,0 +1,21 @@ +-module(dreki_web_task). +-behaviour(cowboy_handler). +-behaviour(cowboy_rest). +-export([init/2]). +-export([allowed_methods/2]). +-export([content_types_accepted/2]). +-export([content_types_provided/2]). + +init(Req, State) -> + {cowboy_rest, Req, State}. + +allowed_methods(Req, State) -> + {[<<"GET">>, <<"HEAD">>, <<"OPTIONS">>, <<"POST">>], Req, State}. + +content_types_accepted(Req, State) -> + dreki_web:content_types_accepted(Req, State). + +content_types_provided(Req, State) -> + dreki_web:content_types_provided(Req, State). + + diff --git a/apps/dreki_web/src/dreki_web_ui.erl b/apps/dreki_web/src/dreki_web_ui.erl new file mode 100644 index 0000000..d60cc6e --- /dev/null +++ b/apps/dreki_web/src/dreki_web_ui.erl @@ -0,0 +1,43 @@ +-module(dreki_web_ui). +-export([render/3, reply_html/3, reply_html/4]). + +render(Req, InnerModule, Assigns0) -> + Assigns = assigns(Req, Assigns0), + {ok, InnerHtml} = InnerModule:render(Assigns), + render_layout(Req, InnerHtml, Assigns). + +reply_html(Req, Code, Html) -> + reply_html(Req, Code, Html, #{}). + +reply_html(Req, Code, Html, Headers0) -> + Headers = maps:put(<<"content-type">>, <<"text/html">>, Headers0), + cowboy_req:reply(Code, Headers, Html, Req). + +render_layout(Req, InnerHtml, Assigns) -> + {ok, Html} = layout_dtl:render([{"inner", InnerHtml} | Assigns]), + Html. + +assigns(Req, Assigns0) -> + Assigns = clean_assigns(Assigns0), + [{"site_title", "Dreki"}, + {"identity_id", maps:get(identity_id, Req)}, + {"identity", maps:get(identity, Req)}, + {"identity_name", dreki_web:identity_name(Req)}, + {"dreki_node", node()}, + {"dreki_world", dreki_world:to_map()} + | Assigns]. + +content_types_accepted(Req, State) -> + {[ + {{ <<"multipart">>, <<"form-data">>, '*'}, from_form} + ], Req, State}. + +content_types_provided(Req, State) -> + {[{{ <<"text">>, <<"html">>, '*'}, to_html}], Req, State}. + +clean_assigns(Assigns) when is_list(Assigns) -> + Assigns; +clean_assigns(Map) when is_map(Map) -> + maps:fold(fun (Key, Value, Acc) -> [{Key, clean_assigns(Value)} | Acc] end, [], Map); +clean_assigns(Other) -> + Other. diff --git a/apps/dreki_web/src/dreki_web_ui_error.erl b/apps/dreki_web/src/dreki_web_ui_error.erl new file mode 100644 index 0000000..ccda150 --- /dev/null +++ b/apps/dreki_web/src/dreki_web_ui_error.erl @@ -0,0 +1,14 @@ +-module(dreki_web_ui_error). +-behaviour(cowboy_handler). +-export([init/2]). + +init(Req, not_found) -> + reply(Req, 404, <<"Not Found">>, undefined); +init(Req, State = #{code := Code, status := Status}) -> + reply(Req, Code, Status, maps:get(message, State, undefined)). + +reply(Req0, Code, Status, Msg) -> + Assigns = [{"message", Msg}, {"status", Status}], + Html = dreki_web_ui:render(Req0, error_dtl, Assigns), + Req = dreki_web_ui:reply_html(Req0, Code, Html), + {ok, Req, undefined}. diff --git a/apps/dreki_web/src/dreki_web_ui_index.erl b/apps/dreki_web/src/dreki_web_ui_index.erl new file mode 100644 index 0000000..9f4684e --- /dev/null +++ b/apps/dreki_web/src/dreki_web_ui_index.erl @@ -0,0 +1,31 @@ +-module(dreki_web_ui_index). +-behaviour(cowboy_handler). +-export([init/2]). + +init(Req = #{method := <<"GET">>}, State) -> + PrettyWorld = jsone:encode(dreki_world:to_map(), [canonical_form, {space, 1}, {indent, 4}]), + LocalTasksStores = dreki_tasks:local_stores(), + {ok, Peers} = partisan_peer_service:members(), + NavTree = nav_tree(dreki_world_dns:as_map()), + Html = dreki_web_ui:render(Req, index_dtl, [ + {"page_title", "Admin UI"}, + {"dreki_world_pretty_json", PrettyWorld}, + {"dreki_local_tasks_stores", LocalTasksStores}, + {"dreki_peers", Peers}, + {"nav_tree", NavTree} + ]), + {ok, dreki_web_ui:reply_html(Req, 200, Html), State}; +init(Req, _) -> + dreki_web_ui_error:init(Req, #{code => 400, status => "Bad request"}). + +nav_tree(#{vertices := Vertices}) -> + nav_tree(Vertices, []). + +nav_tree([Region = #{type := region, name := Name, data := #{display_name := Display}} | Rest], Acc) -> + nav_tree(Rest, [Region#{href => <<"/admin/regions/", Name/binary>>, title => Display} | Acc]); +nav_tree([Node = #{type := node, name := Name, data := #{display_name := Display}} | Rest], Acc) -> + nav_tree(Rest, [Node#{href => <<"/admin/nodes/", Name/binary>>, title => Display} | Acc]); +nav_tree([#{type := Root} | Rest], Acc) -> + nav_tree(Rest, Acc); +nav_tree([], Acc) -> + Acc. diff --git a/apps/dreki_web/src/dreki_web_ui_json_form.erl b/apps/dreki_web/src/dreki_web_ui_json_form.erl new file mode 100644 index 0000000..49f69f8 --- /dev/null +++ b/apps/dreki_web/src/dreki_web_ui_json_form.erl @@ -0,0 +1,127 @@ +-module(dreki_web_ui_json_form). +-export([render_html/2]). +-export([render/2]). +-export([to_html/1]). + +-type dreki_form() :: #{ + input => binary(), + label => binary() +}. + +render_html(Schema, Opts) -> + Abstract = render(Schema, Opts), + to_html(Abstract). + +to_html(Atom) when is_atom(Atom) -> + as_binary(Atom); +to_html(Binary) when is_binary(Binary) -> + Binary; +to_html(Abstract) -> + to_html(Abstract, []). + +to_html([{Node, Attrs} | Rest], Acc) -> + BNode = as_binary(Node), + AttrsS = attrs_to_html(Attrs), + to_html(Rest, [[<<"<">>, BNode, <<" ">>, AttrsS, <<">">>] | Acc]); +to_html([{Node, Attrs, Content} | Rest], Acc) -> + BNode = as_binary(Node), + AttrsS = attrs_to_html(Attrs), + logger:debug("Node ~p Attrs ~p Content ~p", [Node, Attrs, Content]), + to_html(Rest, [[<<"<">>, BNode, <<" ">>, AttrsS, <<">">>, to_html(Content), <<"</">>, BNode, <<">">>] | Acc]); +to_html([], Acc) -> + lists:reverse(Acc). + +attrs_to_html(Attrs) -> attrs_to_html(Attrs, []). +attrs_to_html([{Attr, undefined} | Rest], Acc) -> + attrs_to_html(Rest, Acc); +attrs_to_html([{Attr, false} | Rest], Acc) -> + attrs_to_html(Rest, Acc); +attrs_to_html([{Attr, true} | Rest], Acc) -> + attrs_to_html(Rest, [[as_binary(Attr), <<" ">>] | Acc]); +attrs_to_html([{Attr, Num} | Rest], Acc) when is_integer(Num) -> + attrs_to_html(Rest, [[as_binary(Attr), <<"=">>, Num, <<" ">>] | Acc]); +attrs_to_html([{Attr, Value} | Rest], Acc) -> + attrs_to_html(Rest, [[as_binary(Attr), <<"=">>, <<"\"">>, as_binary(Value), <<"\"">>, <<" ">>] | Acc]); +attrs_to_html([], Acc) -> + Acc. + +render(Schema, Opts) -> + lists:reverse(maps:fold(fun (Key, Value, Acc) -> + case render_property(Key, Value, Schema, Schema, Opts) of + {ok, Html} -> [Html | Acc]; + ignore -> Acc + end + end, [], maps:get(properties, Schema))). + +render_property(Field, Config = #{enum := Enum}, Parent, Schema, Opts) -> + FormAttrs = maps:get(<<"dreki:form">>, Config, #{}), + Required = lists:member(Field, maps:get(required, Parent, [])), + Label = maps:get(label, FormAttrs, maps:get(title, Config, Field)), + InputOpts = #{required => Required, label => Label, values => Enum}, + {ok, render_input(select, Field, undefined, InputOpts, Opts)}; + +render_property(Field, Config = #{type := Type}, Parent, Schema, Opts) -> + FormAttrs = maps:get(<<"dreki:form">>, Config, #{}), + Input = maps:get(input, FormAttrs, input), + InputType = maps:get(input_type, FormAttrs, input_type(Type)), + Required = lists:member(Field, maps:get(required, Parent, [])), + Label = maps:get(label, FormAttrs, maps:get(title, Config, Field)), + InputOpts = #{required => Required, label => Label, input_type => InputType}, + {ok, render_input(Input, Field, Type, InputOpts, Opts)}; + +render_property(Field, #{'$ref' := Ref}, Parent, Schema = #{'$defs' := Defs}, Opts) -> + case maps:get(Ref, Defs, undefined) of + undefined -> logger:error("didn't get ref ~p", [Ref]); + ExpandedRef -> + logger:debug("Skipping ref for now: ~p ~p", [Ref, ExpandedRef]) + end, + ignore. + +base_attributes(Field, IOpts, FOpts) -> + Name = field_name(Field, FOpts), + {Name, [ + {id, field_id(Field, FOpts)}, + {name, Name}, + {required, maps:get(required, IOpts, false)} + ]}. + +render_input(select, Field, _, IOpts, FOpts) -> + {Name, Attributes} = base_attributes(Field, IOpts, FOpts), + OptionsHtml = lists:map(fun (Opt) -> {option, [], Opt} end, maps:get(values, IOpts, [])), + {'div', [{class, <<"json-field">>}], [ + {label, [{for, Name}], maps:get(label, IOpts, Field)}, + {select, Attributes, OptionsHtml} + ]}; + +render_input(input, Field, Type, IOpts, FOpts) -> + {Name, Attributes0} = base_attributes(Field, IOpts, FOpts), + Attributes = [ + {value, maps:get(value, IOpts, undefined)}, + {placeholder, maps:get(placeholder, IOpts, undefined)}, + {readonly, maps:get(readonly, IOpts, false)}, + {autocomplete, maps:get(autocomplete, IOpts, <<"off">>)}, + {type, maps:get(input_type, IOpts)}, + {class, <<"shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 rounded-md">>} + | Attributes0], + HtmlNode = {'div', [{class, <<"json-field">>}], [ + {label, [{for, Name}, {class, <<"block text-sm font-medium text-gray-700">>}], maps:get(label, IOpts, Field)}, + {'div', [{class, <<"mt-1">>}], [{input, Attributes}]} + ]}, + HtmlNode. + +as_binary(Atom) when is_atom(Atom) -> + atom_to_binary(Atom); +as_binary(B) when is_binary(B) -> + B. + +input_type(_) -> <<"text">>. + +field_name(Field, Opts) -> + FB = as_binary(Field), + BaseName = maps:get(name, Opts, <<"form">>), + <<"[", BaseName/binary, "]", FB/binary>>. + +field_id(Field, Opts) -> + FB = as_binary(Field), + BaseId = maps:get(id, Opts, <<"form">>), + <<BaseId/binary, "-", FB/binary>>. diff --git a/apps/dreki_web/src/dreki_web_ui_node.erl b/apps/dreki_web/src/dreki_web_ui_node.erl new file mode 100644 index 0000000..fdb7c77 --- /dev/null +++ b/apps/dreki_web/src/dreki_web_ui_node.erl @@ -0,0 +1,23 @@ +-module(dreki_web_ui_node). +-behaviour(cowboy_handler). +-behaviour(cowboy_rest). +-export([init/2]). +-export([allowed_methods/2]). +-export([content_types_accepted/2]). +-export([content_types_provided/2]). + +init(Req, State) -> + {cowboy_rest, Req, State}. + +allowed_methods(Req, State) -> + {[<<"GET">>, <<"HEAD">>, <<"OPTIONS">>, <<"POST">>], Req, State}. + +content_types_accepted(Req, State) -> + dreki_web:content_types_accepted(Req, State). + +content_types_provided(Req, State) -> + dreki_web:content_types_provided(Req, State). + +to_html(Req, State) -> + Html = dreki_web_ui:render(Req, node, []), + {Html, Req, State}. diff --git a/apps/dreki_web/src/dreki_web_ui_stores.erl b/apps/dreki_web/src/dreki_web_ui_stores.erl new file mode 100644 index 0000000..d39e571 --- /dev/null +++ b/apps/dreki_web/src/dreki_web_ui_stores.erl @@ -0,0 +1,126 @@ +-module(dreki_web_ui_stores). +-behaviour(cowboy_handler). +-export([init/2]). + + +init(Req, action) -> + with_location(Req#{action => cowboy_req:binding(action, Req)}, cowboy_req:binding(location, Req, undefined)); +init(Req, Action) -> + with_location(Req#{action => Action}, cowboy_req:binding(location, Req, undefined)). + +with_location(Req, undefined) -> + with_namespace(Req#{urn => dreki_world:root_path()}); +with_location(Req, <<"-">>) -> + with_namespace(Req#{urn => dreki_world:root_path()}); +with_location(Req0, Location) -> + Root = dreki_world:root_path(), + {ok, XUrn} = dreki_urn:expand(<<Root/binary, ":", Location/binary>>), + Req = Req0#{urn => maps:get(urn, XUrn)}, + with_namespace(Req). + +with_namespace(Req) -> + with_namespace(Req, cowboy_req:binding(namespace, Req)). +with_namespace(Req, undefined) -> + request(Req); +with_namespace(Req = #{urn := Urn0}, Namespace) -> + Urn = <<Urn0/binary, "::", Namespace/binary>>, + with_directory(Req#{urn => Urn}). + +with_directory(Req) -> + with_directory(Req, cowboy_req:binding(directory, Req)). +with_directory(Req, undefined) -> + request(Req); +with_directory(Req = #{urn := Urn0}, Directory) -> + Urn = <<Urn0/binary, ":", Directory/binary>>, + with_id(Req#{urn => Urn}). + +with_id(Req) -> + with_id(Req, cowboy_req:binding(id, Req)). +with_id(Req, undefined) -> + request(Req); +with_id(Req = #{urn := Urn0}, Id) -> + Urn = <<Urn0/binary, ":", Id/binary>>, + request(Req#{urn => Urn}). + +request(Req = #{action := Action, method := Method, urn := Urn}) -> + logger:debug("Faked Urn ~p", [Urn]), + case dreki_urn:expand(Urn) of + {ok, XUrn} -> request(Method, Action, XUrn, Req); + {error, EMap=#{}} -> dreki_web_error:init(Req, EMap); + {error, _Error} -> dreki_web_error:init(Req, #{code => 404, status => "Not Found"}) + end. + +%% Stores list +request(<<"GET">>, undefined, #{resource := #{namespace := NS}}, Req) -> + {ok, Stores0} = dreki_store:stores(NS), + Stores = lists:map(fun (Store) -> + S = dreki_store:store_as_map(Store), + S#{href => urn_to_path(maps:get(urn, S))} + end, Stores0), + Html = dreki_web_ui:render(Req, namespace_dtl, [{namespace, NS}, {stores, Stores}]), + {ok, dreki_web_ui:reply_html(Req, 200, Html), undefined}; + +%% List +request(<<"GET">>, undefined, Urn = #{location := Loc, resource := #{directory := #{directory := Dir, namespace := NS}}}, Req) -> + {ok, Result} = dreki_store:list(Urn), + Results = lists:map(fun(Result) -> + Result#{href => urn_to_path(maps:get('@id', Result))} + end, maps:get(data, Result)), + Html = dreki_web_ui:render(Req, store_list_dtl, [ + {location, Loc}, {namespace, NS}, {directory, Dir}, {results, Results}, + {new, href(Req, <<"_/new">>)} + ]), + {ok, dreki_web_ui:reply_html(Req, 200, Html), undefined}; + +%% New +request(<<"GET">>, <<"new">>, #{urn := Urn, location := Loc, resource := #{directory := #{directory := Dir, namespace := NS}}}, Req) -> + logger:debug("Actual Urn: ~p", [Urn]), + {ok, Schemas} = dreki_store:list(<<Urn/binary, "::", "schemas">>), + {ok, Schema} = dreki_store:get(<<Urn/binary, "::", "schemas::">>), + Form = dreki_web_ui_json_form:render_html(Schema, #{}), + Html = dreki_web_ui:render(Req, store_new_dtl, [ + {location, Loc}, {namespace, NS}, {directory, Dir}, + {schema, Schema}, {schemas, Schemas}, + {target, urn_to_path(Urn)}, {method, <<"POST">>}, + {form, Form} + ]), + {ok, dreki_web_ui:reply_html(Req, 200, Html), undefined}; + +%% Show +request(<<"GET">>, undefined, Urn = #{location := Loc, resource := #{resource := #{id := Id, directory := Dir, namespace := NS}}}, Req) -> + {ok, Result0} = dreki_store:get(Urn), + Result = Result0#{'@href' => urn_to_path(Urn)}, + Html = dreki_web_ui:render(Req, store_show_dtl, [{location, Loc}, {id, Id}, {directory, Dir}, {namespace, NS}, {result, Result}]), + {ok, dreki_web_ui:reply_html(Req, 200, Html), undefined}. + +derpinit(Req, _) -> + Json = #{<<"error">> => false, <<"service">> => <<"dreki">>}, + logger:debug("REQ: ~p", [Req]), + {ok, dreki_web:reply_json(Req, 200, Json), undefined}. + + +bad_request(Req) -> + dreki_web_error:init(Req, #{code => 400, status => "Bad request"}). + +href(Req = #{path := Path}, Append) -> + <<Path/binary, "/", Append/binary>>. + +location_to_path(Location) -> + Root = dreki_world:root_path(), + case Location =:= Root of + true -> <<"/admin/-">>; + false -> binary:replace(Location, <<Root/binary, ":">>, <<"/admin/">>) + end. + +urn_to_path(Urn) when is_binary(Urn) -> + {ok, XUrn} = dreki_urn:expand(Urn), + urn_to_path(XUrn); +urn_to_path(#{location := Location, resource := #{namespace := NS}}) -> + LP = location_to_path(Location), + <<LP/binary, "/", NS/binary>>; +urn_to_path(#{location := Location, resource := #{directory := #{directory := Dir, namespace := NS}}}) -> + LP = location_to_path(Location), + <<LP/binary, "/", NS/binary, "/", Dir/binary>>; +urn_to_path(#{location := Location, resource := #{resource := #{id := Id, directory := Dir, namespace := NS}}}) -> + LP = location_to_path(Location), + <<LP/binary, "/", NS/binary, "/", Dir/binary, "/", Id/binary>>. diff --git a/apps/dreki_web/src/dreki_web_ui_task.erl b/apps/dreki_web/src/dreki_web_ui_task.erl new file mode 100644 index 0000000..1ced583 --- /dev/null +++ b/apps/dreki_web/src/dreki_web_ui_task.erl @@ -0,0 +1,20 @@ +-module(dreki_web_ui_task). +-behaviour(cowboy_handler). +-export([init/2]). + +init(Req = #{method := <<"GET">>}, State) -> + Id = cowboy_req:binding(id, Req), + {ok, Db} = dreki_tasks:open(), + case dreki_tasks:get(Db, Id) of + {ok, Task} -> + MTask = dreki_task:to_map(Task), + PrettyParams = jsone:encode(maps:get(params, MTask), [canonical_form, {space, 1}, {indent, 4}]), + Html = dreki_web_ui:render(Req, task_dtl, [{"page_title", <<"Task: ", Id/binary>>}, {"task", MTask}, {"task_pretty_params", PrettyParams}]), + {ok, dreki_web_ui:reply_html(Req, 200, Html), State}; + Error -> + logger:debug("Failed to lookup task ~p: ~p", [Id, Error]), + dreki_web_ui_error:init(Req, #{code => 404, status => "Not found"}) + end; +init(Req, _) -> + dreki_web_ui_error:init(Req, #{code => 400, status => "Bad request"}). + diff --git a/apps/dreki_web/src/dreki_web_ui_tasks.erl b/apps/dreki_web/src/dreki_web_ui_tasks.erl new file mode 100644 index 0000000..e9748b8 --- /dev/null +++ b/apps/dreki_web/src/dreki_web_ui_tasks.erl @@ -0,0 +1,14 @@ +-module(dreki_web_ui_tasks). +-behaviour(cowboy_handler). +-export([init/2]). + +init(Req = #{method := <<"GET">>}, State) -> + Local = maps:fold(fun + (Ln, #{mod := Mod, path := Path}, Acc) -> + [#{name => Ln, mod => Mod, path => Path, url => <<"/api/admin/tasks/", Path>>} | Acc] + end, [], dreki_tasks:local_stores()), + Html = dreki_web_ui:render(Req, tasks_dtl, [{"page_title", "Tasks"}, {"stores", Local}, {"tasks", []}]), + {ok, dreki_web_ui:reply_html(Req, 200, Html), State}; +init(Req, _) -> + dreki_web_ui_error:init(Req, #{code => 400, status => "Bad request"}). + |