Current section

Files

Jump to
multipartkit src multipartkit@form.erl
Raw

src/multipartkit@form.erl

-module(multipartkit@form).
-compile([no_auto_import, nowarn_unused_vars, nowarn_unused_function, nowarn_nomatch, inline]).
-define(FILEPATH, "src/multipartkit/form.gleam").
-export([new/0, parts/1, unsafe_add_part/2, add_field/3, add_file/5, add_file_auto_with/5, add_file_auto/4]).
-export_type([form/0]).
-if(?OTP_RELEASE >= 27).
-define(MODULEDOC(Str), -moduledoc(Str)).
-define(DOC(Str), -doc(Str)).
-else.
-define(MODULEDOC(Str), -compile([])).
-define(DOC(Str), -compile([])).
-endif.
-opaque form() :: {form, list(multipartkit@part:part())}.
-file("src/multipartkit/form.gleam", 21).
?DOC(" A new empty form.\n").
-spec new() -> form().
new() ->
{form, []}.
-file("src/multipartkit/form.gleam", 121).
?DOC(" Read the parts in insertion order.\n").
-spec parts(form()) -> list(multipartkit@part:part()).
parts(Form) ->
lists:reverse(erlang:element(2, Form)).
-file("src/multipartkit/form.gleam", 125).
-spec push(form(), multipartkit@part:part()) -> form().
push(Form, The_part) ->
{form, [The_part | erlang:element(2, Form)]}.
-file("src/multipartkit/form.gleam", 116).
?DOC(
" Append a pre-built `Part` without validation or normalisation.\n"
"\n"
" The caller is responsible for keeping `headers`, `name`, `filename`, and\n"
" `content_type` mutually consistent. Prefer `add_field` / `add_file` /\n"
" `add_file_auto` for library-maintained consistency.\n"
).
-spec unsafe_add_part(form(), multipartkit@part:part()) -> form().
unsafe_add_part(Form, The_part) ->
push(Form, The_part).
-file("src/multipartkit/form.gleam", 192).
-spec is_ascii_safe_loop(bitstring()) -> boolean().
is_ascii_safe_loop(Bytes) ->
case Bytes of
<<>> ->
true;
<<B, Rest/binary>> ->
case (B =:= 16#09) orelse ((B >= 16#20) andalso (B =< 16#7E)) of
true ->
is_ascii_safe_loop(Rest);
false ->
false
end;
_ ->
false
end.
-file("src/multipartkit/form.gleam", 188).
?DOC(
" True iff every code point in `s` is printable US-ASCII (0x20-0x7E)\n"
" or HTAB. Matches the subset of bytes RFC 7230 §3.2.4 admits as\n"
" header field values.\n"
).
-spec is_ascii_safe(binary()) -> boolean().
is_ascii_safe(S) ->
is_ascii_safe_loop(gleam_stdlib:identity(S)).
-file("src/multipartkit/form.gleam", 211).
-spec ascii_fallback_loop(list(binary()), binary()) -> binary().
ascii_fallback_loop(Remaining, Acc) ->
case Remaining of
[] ->
Acc;
[Grapheme | Rest] ->
case is_ascii_safe(Grapheme) of
true ->
ascii_fallback_loop(Rest, <<Acc/binary, Grapheme/binary>>);
false ->
ascii_fallback_loop(Rest, <<Acc/binary, "_"/utf8>>)
end
end.
-file("src/multipartkit/form.gleam", 207).
?DOC(
" Replace every non-ASCII-safe code point in `s` with `_` so the\n"
" result is safe for use inside a legacy `filename=\"...\"`. Used as\n"
" the pre-RFC-5987 fallback alongside `filename*=`.\n"
).
-spec ascii_fallback(binary()) -> binary().
ascii_fallback(S) ->
ascii_fallback_loop(gleam@string:to_graphemes(S), <<""/utf8>>).
-file("src/multipartkit/form.gleam", 253).
-spec is_attr_char(integer()) -> boolean().
is_attr_char(B) ->
(((((((((((((((B >= 16#41) andalso (B =< 16#5A)) orelse ((B >= 16#61)
andalso (B =< 16#7A)))
orelse ((B >= 16#30) andalso (B =< 16#39)))
orelse (B =:= 16#21))
orelse (B =:= 16#23))
orelse (B =:= 16#24))
orelse (B =:= 16#26))
orelse (B =:= 16#2B))
orelse (B =:= 16#2D))
orelse (B =:= 16#2E))
orelse (B =:= 16#5E))
orelse (B =:= 16#5F))
orelse (B =:= 16#60))
orelse (B =:= 16#7C))
orelse (B =:= 16#7E).
-file("src/multipartkit/form.gleam", 277).
-spec hex_digit(integer()) -> binary().
hex_digit(N) ->
case N of
0 ->
<<"0"/utf8>>;
1 ->
<<"1"/utf8>>;
2 ->
<<"2"/utf8>>;
3 ->
<<"3"/utf8>>;
4 ->
<<"4"/utf8>>;
5 ->
<<"5"/utf8>>;
6 ->
<<"6"/utf8>>;
7 ->
<<"7"/utf8>>;
8 ->
<<"8"/utf8>>;
9 ->
<<"9"/utf8>>;
10 ->
<<"A"/utf8>>;
11 ->
<<"B"/utf8>>;
12 ->
<<"C"/utf8>>;
13 ->
<<"D"/utf8>>;
14 ->
<<"E"/utf8>>;
15 ->
<<"F"/utf8>>;
_ ->
erlang:integer_to_binary(N)
end.
-file("src/multipartkit/form.gleam", 273).
-spec percent_byte(integer()) -> binary().
percent_byte(B) ->
<<<<"%"/utf8, (hex_digit(B div 16))/binary>>/binary,
(hex_digit(B rem 16))/binary>>.
-file("src/multipartkit/form.gleam", 231).
-spec percent_encode_loop(bitstring(), binary()) -> binary().
percent_encode_loop(Bytes, Acc) ->
case Bytes of
<<>> ->
Acc;
<<B, Rest/binary>> ->
Chunk = case is_attr_char(B) of
false ->
percent_byte(B);
true ->
_pipe = gleam@bit_array:to_string(<<B>>),
gleam@result:unwrap(_pipe, percent_byte(B))
end,
percent_encode_loop(Rest, <<Acc/binary, Chunk/binary>>);
_ ->
Acc
end.
-file("src/multipartkit/form.gleam", 227).
?DOC(
" Percent-encode the UTF-8 byte representation of `s` per RFC 5987\n"
" §3.2.1 attr-char. Bytes that match the attr-char production\n"
" (`ALPHA / DIGIT / \"!\" / \"#\" / \"$\" / \"&\" / \"+\" / \"-\" / \".\" / \"^\" /\n"
" \"_\" / \"`\" / \"|\" / \"~\"`) pass through unencoded; every other byte\n"
" is emitted as `%HH` with uppercase hex digits.\n"
).
-spec percent_encode_rfc5987(binary()) -> binary().
percent_encode_rfc5987(S) ->
percent_encode_loop(gleam_stdlib:identity(S), <<""/utf8>>).
-file("src/multipartkit/form.gleam", 302).
?DOC(
" Strip CR, LF, and NUL from `value`. Used to neutralise header injection\n"
" in any value that ends up on a header line — `name`, `filename`, and\n"
" `content_type`.\n"
).
-spec sanitize_value(binary()) -> binary().
sanitize_value(Value) ->
_pipe = Value,
_pipe@1 = gleam@string:replace(_pipe, <<"\r\n"/utf8>>, <<""/utf8>>),
_pipe@2 = gleam@string:replace(_pipe@1, <<"\r"/utf8>>, <<""/utf8>>),
_pipe@3 = gleam@string:replace(_pipe@2, <<"\n"/utf8>>, <<""/utf8>>),
gleam@string:replace(_pipe@3, <<"\x{0000}"/utf8>>, <<""/utf8>>).
-file("src/multipartkit/form.gleam", 314).
-spec escape_quoted(binary(), binary()) -> binary().
escape_quoted(Remaining, Acc) ->
case gleam_stdlib:string_pop_grapheme(Remaining) of
{error, nil} ->
Acc;
{ok, {<<"\\"/utf8>>, Rest}} ->
escape_quoted(Rest, <<Acc/binary, "\\\\"/utf8>>);
{ok, {<<"\""/utf8>>, Rest@1}} ->
escape_quoted(Rest@1, <<Acc/binary, "\\\""/utf8>>);
{ok, {Other, Rest@2}} ->
escape_quoted(Rest@2, <<Acc/binary, Other/binary>>)
end.
-file("src/multipartkit/form.gleam", 310).
-spec quote(binary()) -> binary().
quote(Value) ->
<<<<"\""/utf8, (escape_quoted(Value, <<""/utf8>>))/binary>>/binary,
"\""/utf8>>.
-file("src/multipartkit/form.gleam", 33).
?DOC(
" Append a text field. `value` is encoded as UTF-8 in the part body. No\n"
" filename is set.\n"
"\n"
" Carriage returns, line feeds, and NUL bytes in `name` are silently\n"
" stripped to prevent header injection. The cached `name` on the resulting\n"
" `Part` reflects the sanitized value, matching what a parse-after-encode\n"
" round-trip would produce. Use `unsafe_add_part` if byte-exact\n"
" preservation is required.\n"
).
-spec add_field(form(), binary(), binary()) -> form().
add_field(Form, Name, Value) ->
Safe_name = sanitize_value(Name),
Disposition = {<<"Content-Disposition"/utf8>>,
<<"form-data; name="/utf8, (quote(Safe_name))/binary>>},
New_part = multipartkit@part:unchecked_new(
[Disposition],
{some, Safe_name},
none,
none,
<<Value/binary>>
),
push(Form, New_part).
-file("src/multipartkit/form.gleam", 174).
?DOC(
" Build the `; filename=...` portion of a Content-Disposition header\n"
" for a file part.\n"
"\n"
" For ASCII-safe filenames (every code point is printable US-ASCII\n"
" per RFC 7230 §3.2.4) the legacy `filename=\"...\"` form is sufficient\n"
" and is emitted unchanged.\n"
"\n"
" For filenames that contain bytes outside that range, the legacy\n"
" form would produce a header value that violates RFC 7230 §3.2.4 and\n"
" is mangled or rejected by strict HTTP intermediaries. We emit BOTH:\n"
"\n"
" - `filename=\"<ascii-fallback>\"` — non-ASCII code points replaced\n"
" with `_`. Lets pre-RFC-5987 clients still see a sensible name.\n"
" - `filename*=UTF-8''<percent-encoded>` — RFC 5987 §3.2.1 / RFC 6266\n"
" §4.3 form, faithful round-trip for spec-aware parsers.\n"
"\n"
" RFC 5987 §3.2.2: when both forms are present, the `*=` form takes\n"
" precedence — multipartkit's own `content_disposition.parse` already\n"
" honours that ordering, as do all browsers and the major HTTP\n"
" servers.\n"
).
-spec filename_disposition_params(binary()) -> binary().
filename_disposition_params(Filename) ->
case is_ascii_safe(Filename) of
true ->
<<"; filename="/utf8, (quote(Filename))/binary>>;
false ->
<<<<<<"; filename="/utf8, (quote(ascii_fallback(Filename)))/binary>>/binary,
"; filename*=UTF-8''"/utf8>>/binary,
(percent_encode_rfc5987(Filename))/binary>>
end.
-file("src/multipartkit/form.gleam", 129).
-spec build_file_part(binary(), binary(), binary(), bitstring()) -> multipartkit@part:part().
build_file_part(Name, Filename, Content_type, Body) ->
Safe_name = sanitize_value(Name),
Safe_filename = sanitize_value(Filename),
Safe_content_type = sanitize_value(Content_type),
Disposition_header = {<<"Content-Disposition"/utf8>>,
<<<<"form-data; name="/utf8, (quote(Safe_name))/binary>>/binary,
(filename_disposition_params(Safe_filename))/binary>>},
Content_type_header = {<<"Content-Type"/utf8>>, Safe_content_type},
multipartkit@part:unchecked_new(
[Disposition_header, Content_type_header],
{some, Safe_name},
{some, Safe_filename},
{some, Safe_content_type},
Body
).
-file("src/multipartkit/form.gleam", 57).
?DOC(
" Append a file part with an explicit content type.\n"
"\n"
" Carriage returns, line feeds, and NUL bytes in `name`, `filename`, and\n"
" `content_type` are silently stripped to prevent header injection. The\n"
" cached `name`, `filename`, and `content_type` on the resulting `Part`\n"
" reflect the sanitized values. Use `unsafe_add_part` if byte-exact\n"
" preservation is required.\n"
).
-spec add_file(form(), binary(), binary(), binary(), bitstring()) -> form().
add_file(Form, Name, Filename, Content_type, Body) ->
push(Form, build_file_part(Name, Filename, Content_type, Body)).
-file("src/multipartkit/form.gleam", 93).
?DOC(
" Append a file part, inferring the content type via the supplied\n"
" `Inferer`.\n"
"\n"
" Inference precedence:\n"
"\n"
" 1. `inferer.from_filename(filename)`\n"
" 2. `inferer.from_bytes(body)`\n"
" 3. `application/octet-stream`\n"
"\n"
" The inferred content type is sanitized (CR / LF / NUL stripped) before\n"
" being written to the header.\n"
).
-spec add_file_auto_with(
form(),
binary(),
binary(),
bitstring(),
multipartkit@infer:inferer()
) -> form().
add_file_auto_with(Form, Name, Filename, Body, Inferer) ->
Content_type = case (erlang:element(2, Inferer))(Filename) of
{some, Value} ->
Value;
none ->
case (erlang:element(3, Inferer))(Body) of
{some, Value@1} ->
Value@1;
none ->
<<"application/octet-stream"/utf8>>
end
end,
add_file(Form, Name, Filename, Content_type, Body).
-file("src/multipartkit/form.gleam", 73).
?DOC(
" Append a file part using the default (no-op) inferer.\n"
"\n"
" Equivalent to `add_file_auto_with(form, name, filename, body,\n"
" infer.default_inferer())`. The default inferer returns `None` from both\n"
" helpers in v0.1.0, so this falls through to `application/octet-stream`\n"
" unless you call `add_file_auto_with` with a real inferer.\n"
).
-spec add_file_auto(form(), binary(), binary(), bitstring()) -> form().
add_file_auto(Form, Name, Filename, Body) ->
add_file_auto_with(
Form,
Name,
Filename,
Body,
multipartkit@infer:default_inferer()
).