Current section

Files

Jump to
dream src dream@controllers@static.erl
Raw

src/dream@controllers@static.erl

-module(dream@controllers@static).
-compile([no_auto_import, nowarn_unused_vars, nowarn_unused_function, nowarn_nomatch, inline]).
-define(FILEPATH, "src/dream/controllers/static.gleam").
-export([default_config/0, with_directory_listing/1, with_custom_404/2, without_index/1, serve/6]).
-export_type([config/2]).
-if(?OTP_RELEASE >= 27).
-define(MODULEDOC(Str), -moduledoc(Str)).
-define(DOC(Str), -doc(Str)).
-else.
-define(MODULEDOC(Str), -compile([])).
-define(DOC(Str), -compile([])).
-endif.
?MODULEDOC(
" Secure static file serving\n"
"\n"
" Serve files from disk with built-in security. Handles CSS, JavaScript, images,\n"
" and any other static assets your application needs.\n"
"\n"
" ## Quick Setup\n"
"\n"
" ```gleam\n"
" import dream/controllers/static.{default_config, serve}\n"
" import dream/http/request.{type Request, Get, get_string_param}\n"
" import dream/http/response.{type Response, json_response}\n"
" import dream/http/status.{bad_request}\n"
" import dream/router.{route}\n"
"\n"
" pub fn serve_assets(request: Request, context, services) -> Response {\n"
" case get_string_param(request, \"path\") {\n"
" Ok(path) -> \n"
" serve(\n"
" request: request,\n"
" context: context,\n"
" services: services,\n"
" root: \"./public\",\n"
" filepath: path,\n"
" config: default_config(),\n"
" )\n"
" Error(msg) -> json_response(bad_request, error_json(msg))\n"
" }\n"
" }\n"
"\n"
" // In your router:\n"
" route(method: Get, path: \"/assets/**path\", controller: serve_assets, middleware: [])\n"
" ```\n"
"\n"
" ## Security\n"
"\n"
" Built-in protection against:\n"
" - Path traversal attacks (`../../../etc/passwd`)\n"
" - Absolute path access (`/etc/passwd`)\n"
" - Directory escaping\n"
"\n"
" Files outside the root directory return 404, never errors that reveal filesystem structure.\n"
"\n"
" ## Features\n"
"\n"
" - **MIME type detection** - Automatic content-type headers\n"
" - **Index serving** - Serves `index.html` for directory requests\n"
" - **Directory listing** - Optional file browser (disabled by default)\n"
" - **Custom 404s** - Use your own not-found handler\n"
" Control how directories and missing files are handled.\n"
" - Serves `index.html` for directories\n"
" - No directory listing\n"
" - Standard 404 response for missing files\n"
" Shows a file browser when a directory has no `index.html`. Use this for\n"
" development or when you want users to browse files. Don't enable in production\n"
" unless you specifically want directory browsing.\n"
).
-type config(ACJY, ACJZ) :: {config,
boolean(),
boolean(),
gleam@option:option(fun((dream@http@request:request(), ACJY, ACJZ) -> dream@http@response:response()))}.
-file("src/dream/controllers/static.gleam", 82).
?DOC(" Default configuration with secure settings\n").
-spec default_config() -> config(any(), any()).
default_config() ->
{config, true, false, none}.
-file("src/dream/controllers/static.gleam", 92).
?DOC(" Enable directory listing\n").
-spec with_directory_listing(config(ACKE, ACKF)) -> config(ACKE, ACKF).
with_directory_listing(Config) ->
{config, erlang:element(2, Config), true, erlang:element(4, Config)}.
-file("src/dream/controllers/static.gleam", 99).
?DOC(" Set custom 404 handler\n").
-spec with_custom_404(
config(ACKK, ACKL),
fun((dream@http@request:request(), ACKK, ACKL) -> dream@http@response:response())
) -> config(ACKK, ACKL).
with_custom_404(Config, Handler) ->
{config,
erlang:element(2, Config),
erlang:element(3, Config),
{some, Handler}}.
-file("src/dream/controllers/static.gleam", 107).
?DOC(" Disable automatic index.html serving\n").
-spec without_index(config(ACKQ, ACKR)) -> config(ACKQ, ACKR).
without_index(Config) ->
{config, false, erlang:element(3, Config), erlang:element(4, Config)}.
-file("src/dream/controllers/static.gleam", 269).
-spec build_html_response(binary()) -> dream@http@response:response().
build_html_response(Html) ->
{response,
200,
{text, Html},
[{header, <<"Content-Type"/utf8>>, <<"text/html; charset=utf-8"/utf8>>}],
[],
{some, <<"text/html; charset=utf-8"/utf8>>}}.
-file("src/dream/controllers/static.gleam", 279).
-spec build_directory_html(binary(), list(binary())) -> binary().
build_directory_html(Path, Entries) ->
Title = <<"Index of "/utf8, Path/binary>>,
Header = <<<<<<<<"<!DOCTYPE html>
<html>
<head>
<title>"/utf8,
Title/binary>>/binary,
"</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 800px; margin: 40px auto; padding: 20px; }
h1 { border-bottom: 1px solid #ccc; padding-bottom: 10px; }
ul { list-style: none; padding: 0; }
li { padding: 8px; border-bottom: 1px solid #eee; }
li:hover { background: #f5f5f5; }
a { text-decoration: none; color: #0066cc; }
a:hover { text-decoration: underline; }
</style>
</head>
<body>
<h1>"/utf8>>/binary,
Title/binary>>/binary,
"</h1>
<ul>
<li><a href=\"../\">../</a></li>"/utf8>>,
Items = begin
_pipe = Entries,
_pipe@1 = gleam@list:map(
_pipe,
fun(Entry) ->
<<<<<<<<" <li><a href=\""/utf8, Entry/binary>>/binary,
"\">"/utf8>>/binary,
Entry/binary>>/binary,
"</a></li>"/utf8>>
end
),
gleam@string:join(_pipe@1, <<"\n"/utf8>>)
end,
Footer = <<"
</ul>
</body>
</html>"/utf8>>,
<<<<<<Header/binary, "\n"/utf8>>/binary, Items/binary>>/binary,
Footer/binary>>.
-file("src/dream/controllers/static.gleam", 328).
-spec default_404() -> dream@http@response:response().
default_404() ->
{response,
404,
{text, <<"<h1>404 Not Found</h1>"/utf8>>},
[{header, <<"Content-Type"/utf8>>, <<"text/html; charset=utf-8"/utf8>>}],
[],
{some, <<"text/html; charset=utf-8"/utf8>>}}.
-file("src/dream/controllers/static.gleam", 255).
-spec generate_directory_listing(binary(), binary()) -> dream@http@response:response().
generate_directory_listing(Directory, Request_path) ->
case simplifile_erl:read_directory(Directory) of
{ok, Entries} ->
Sorted = gleam@list:sort(Entries, fun gleam@string:compare/2),
Html = build_directory_html(Request_path, Sorted),
build_html_response(Html);
{error, _} ->
default_404()
end.
-file("src/dream/controllers/static.gleam", 316).
-spec handle_not_found(
dream@http@request:request(),
ACLT,
ACLU,
config(ACLT, ACLU)
) -> dream@http@response:response().
handle_not_found(Request, Context, Services, Config) ->
case erlang:element(4, Config) of
{some, Handler} ->
Handler(Request, Context, Services);
none ->
default_404()
end.
-file("src/dream/controllers/static.gleam", 210).
-spec handle_no_index(
dream@http@request:request(),
ACLM,
ACLN,
binary(),
config(ACLM, ACLN)
) -> dream@http@response:response().
handle_no_index(Request, Context, Services, Directory, Config) ->
case erlang:element(3, Config) of
true ->
generate_directory_listing(Directory, erlang:element(5, Request));
false ->
handle_not_found(Request, Context, Services, Config)
end.
-file("src/dream/controllers/static.gleam", 339).
?DOC(" Validate path doesn't escape root directory\n").
-spec is_safe_path(binary()) -> boolean().
is_safe_path(Filepath) ->
not gleam_stdlib:contains_string(Filepath, <<".."/utf8>>) andalso not gleam_stdlib:string_starts_with(
Filepath,
<<"/"/utf8>>
).
-file("src/dream/controllers/static.gleam", 347).
?DOC(" Detect MIME type from file extension using marceau library\n").
-spec mime_type(binary()) -> binary().
mime_type(Filepath) ->
case begin
_pipe = gleam@string:split(Filepath, <<"."/utf8>>),
gleam@list:last(_pipe)
end of
{ok, Ext} ->
marceau:extension_to_mime_type(Ext);
{error, _} ->
<<"application/octet-stream"/utf8>>
end.
-file("src/dream/controllers/static.gleam", 239).
-spec build_file_response(binary(), binary()) -> dream@http@response:response().
build_file_response(Content, Filepath) ->
Mime = mime_type(Filepath),
Size = erlang:byte_size(Content),
{response,
200,
{text, Content},
[{header, <<"Content-Type"/utf8>>, Mime},
{header, <<"Content-Length"/utf8>>, erlang:integer_to_binary(Size)}],
[],
{some, Mime}}.
-file("src/dream/controllers/static.gleam", 231).
-spec serve_file(binary()) -> dream@http@response:response().
serve_file(Filepath) ->
case simplifile:read(Filepath) of
{ok, Content} ->
build_file_response(Content, Filepath);
{error, _} ->
default_404()
end.
-file("src/dream/controllers/static.gleam", 223).
-spec try_serve_index(binary()) -> {ok, dream@http@response:response()} |
{error, nil}.
try_serve_index(Directory) ->
Index_path = <<Directory/binary, "/index.html"/utf8>>,
case simplifile_erl:is_file(Index_path) of
{ok, true} ->
{ok, serve_file(Index_path)};
_ ->
{error, nil}
end.
-file("src/dream/controllers/static.gleam", 191).
-spec handle_directory(
dream@http@request:request(),
ACLI,
ACLJ,
binary(),
config(ACLI, ACLJ)
) -> dream@http@response:response().
handle_directory(Request, Context, Services, Directory, Config) ->
case erlang:element(2, Config) of
true ->
case try_serve_index(Directory) of
{ok, Response} ->
Response;
{error, _} ->
handle_no_index(
Request,
Context,
Services,
Directory,
Config
)
end;
false ->
handle_no_index(Request, Context, Services, Directory, Config)
end.
-file("src/dream/controllers/static.gleam", 178).
-spec handle_directory_or_missing(
dream@http@request:request(),
ACLE,
ACLF,
binary(),
config(ACLE, ACLF)
) -> dream@http@response:response().
handle_directory_or_missing(Request, Context, Services, Path, Config) ->
case simplifile_erl:is_directory(Path) of
{ok, true} ->
handle_directory(Request, Context, Services, Path, Config);
_ ->
handle_not_found(Request, Context, Services, Config)
end.
-file("src/dream/controllers/static.gleam", 158).
-spec serve_validated_path(
dream@http@request:request(),
ACLA,
ACLB,
binary(),
binary(),
config(ACLA, ACLB)
) -> dream@http@response:response().
serve_validated_path(Request, Context, Services, Root, Filepath, Config) ->
Full_path = case Filepath of
<<""/utf8>> ->
Root;
_ ->
<<<<Root/binary, "/"/utf8>>/binary, Filepath/binary>>
end,
case simplifile_erl:is_file(Full_path) of
{ok, true} ->
serve_file(Full_path);
_ ->
handle_directory_or_missing(
Request,
Context,
Services,
Full_path,
Config
)
end.
-file("src/dream/controllers/static.gleam", 142).
?DOC(
" Serve static files from a directory\n"
"\n"
" Security:\n"
" - Prevents path traversal attacks (../)\n"
" - Validates paths stay within root directory\n"
" - Returns 404 for files outside root\n"
"\n"
" Usage:\n"
" ```gleam\n"
" import dream/controllers/static.{default_config, serve}\n"
" import dream/http/request.{type Request, get_string_param}\n"
" import dream/http/response.{type Response, json_response}\n"
" import dream/http/status.{bad_request}\n"
" \n"
" pub fn serve_public(request: Request, context, services) -> Response {\n"
" case get_string_param(request, \"filepath\") {\n"
" Ok(filepath) -> \n"
" serve(\n"
" request: request,\n"
" context: context,\n"
" services: services,\n"
" root: \"./public\",\n"
" filepath: filepath,\n"
" config: default_config(),\n"
" )\n"
" Error(msg) -> json_response(bad_request, error_json(msg))\n"
" }\n"
" }\n"
" ```\n"
).
-spec serve(
dream@http@request:request(),
ACKW,
ACKX,
binary(),
binary(),
config(ACKW, ACKX)
) -> dream@http@response:response().
serve(Request, Context, Services, Root, Filepath, Config) ->
case is_safe_path(Filepath) of
false ->
handle_not_found(Request, Context, Services, Config);
true ->
serve_validated_path(
Request,
Context,
Services,
Root,
Filepath,
Config
)
end.