-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\n" " import dream/http/transaction.{get_string_param}\n" "\n" " pub fn serve_assets(request, ctx, svc) {\n" " case get_string_param(request, \"path\") {\n" " Ok(path) -> static.serve(\n" " request: request,\n" " context: ctx,\n" " services: svc,\n" " root: \"./public\",\n" " filepath: path,\n" " config: static.default_config(),\n" " )\n" " Error(msg) -> json_response(status.bad_request, error_json(msg))\n" " }\n" " }\n" "\n" " // In your router:\n" " router.route(Get, \"/assets/**path\", serve_assets, [])\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(ACJM, ACJN) :: {config, boolean(), boolean(), gleam@option:option(fun((dream@http@request:request(), ACJM, ACJN) -> dream@http@response:response()))}. -file("src/dream/controllers/static.gleam", 78). ?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", 88). ?DOC(" Enable directory listing\n"). -spec with_directory_listing(config(ACJS, ACJT)) -> config(ACJS, ACJT). with_directory_listing(Config) -> {config, erlang:element(2, Config), true, erlang:element(4, Config)}. -file("src/dream/controllers/static.gleam", 95). ?DOC(" Set custom 404 handler\n"). -spec with_custom_404( config(ACJY, ACJZ), fun((dream@http@request:request(), ACJY, ACJZ) -> dream@http@response:response()) ) -> config(ACJY, ACJZ). with_custom_404(Config, Handler) -> {config, erlang:element(2, Config), erlang:element(3, Config), {some, Handler}}. -file("src/dream/controllers/static.gleam", 103). ?DOC(" Disable automatic index.html serving\n"). -spec without_index(config(ACKE, ACKF)) -> config(ACKE, ACKF). without_index(Config) -> {config, false, erlang:element(3, Config), erlang:element(4, Config)}. -file("src/dream/controllers/static.gleam", 259). -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", 269). -spec build_directory_html(binary(), list(binary())) -> binary(). build_directory_html(Path, Entries) -> Title = <<"Index of "/utf8, Path/binary>>, Header = <<<<<<<<" "/utf8, Title/binary>>/binary, "

"/utf8>>/binary, Title/binary>>/binary, "

"/utf8>>, <<<<<
>/binary, Items/binary>>/binary, Footer/binary>>. -file("src/dream/controllers/static.gleam", 318). -spec default_404() -> dream@http@response:response(). default_404() -> {response, 404, {text, <<"

404 Not Found

"/utf8>>}, [{header, <<"Content-Type"/utf8>>, <<"text/html; charset=utf-8"/utf8>>}], [], {some, <<"text/html; charset=utf-8"/utf8>>}}. -file("src/dream/controllers/static.gleam", 245). -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", 306). -spec handle_not_found( dream@http@request:request(), ACLH, ACLI, config(ACLH, ACLI) ) -> 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", 200). -spec handle_no_index( dream@http@request:request(), ACLA, ACLB, binary(), config(ACLA, ACLB) ) -> 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", 329). ?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", 337). ?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", 229). -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", 221). -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", 213). -spec try_serve_index(binary()) -> {ok, dream@http@response:response()} | {error, nil}. try_serve_index(Directory) -> Index_path = <>, case simplifile_erl:is_file(Index_path) of {ok, true} -> {ok, serve_file(Index_path)}; _ -> {error, nil} end. -file("src/dream/controllers/static.gleam", 181). -spec handle_directory( dream@http@request:request(), ACKW, ACKX, binary(), config(ACKW, ACKX) ) -> 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", 168). -spec handle_directory_or_missing( dream@http@request:request(), ACKS, ACKT, binary(), config(ACKS, ACKT) ) -> 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", 148). -spec serve_validated_path( dream@http@request:request(), ACKO, ACKP, binary(), binary(), config(ACKO, ACKP) ) -> dream@http@response:response(). serve_validated_path(Request, Context, Services, Root, Filepath, Config) -> Full_path = case Filepath of <<""/utf8>> -> Root; _ -> <<<>/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", 132). ?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" " pub fn serve_public(request: Request, ctx, svc) -> Response {\n" " case get_string_param(request, \"filepath\") {\n" " Ok(filepath) -> static.serve(\n" " request: request,\n" " context: ctx,\n" " services: svc,\n" " root: \"./public\",\n" " filepath: filepath,\n" " config: static.default_config(),\n" " )\n" " Error(msg) -> json_response(status.bad_request, error_json(msg))\n" " }\n" " }\n" " ```\n" ). -spec serve( dream@http@request:request(), ACKK, ACKL, binary(), binary(), config(ACKK, ACKL) ) -> 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.