Skip to main content

src/nova_multipart_plugin.erl

%%%-------------------------------------------------------------------
%%% @doc Nova Framework Plugin for processing <code>multipart/form-data</code>.
%%% This plugin intercepts incoming requests, parses multipart bodies,
%%% and appends <code>fields</code> and <code>files</code> keys to the Nova Request map.
%%% @end
%%%-------------------------------------------------------------------
-module(nova_multipart_plugin).

%% API exports
-export([pre_request/4, plugin_info/0]).

%%%===================================================================
%%% API Functions
%%%===================================================================

%%--------------------------------------------------------------------
%% @doc Returns metadata information about the plugin.
%% @end
%%--------------------------------------------------------------------
-spec(plugin_info() -> map()).
plugin_info() ->
    #{
        title => <<"Nova Multipart Plugin">>,
        description => <<"Parse multipart body and handle file uploads">>,
        version => <<"0.1.0">>
    }.

%%--------------------------------------------------------------------
%% @doc Intercepts the request and parses it if it's a multipart payload.
%% Nova options can specify custom temporary upload directories.
%% @end
%%--------------------------------------------------------------------
 -spec(pre_request(Req :: map(), Env :: term(), Options :: map(), PluginState :: term()) ->
          {ok, NewReq :: map(), NewPluginState :: term()}).
pre_request(
    #{headers := #{<<"content-type">> := <<"multipart/form-data", _Boundary/binary>>}} = Req,
    _Env,
    Options,
    PluginState
) ->
    %% Logic for multipart
    TmpDir = maps:get(
        tmp_dir,
        Options,
        application:get_env(nova, upload_tmp_dir, filename:basedir(user_cache, "nova_uploads"))
    ),
    filelib:ensure_dir(filename:join(TmpDir, "placeholder")),

    {Req0, Fields0, Files0} = handle_multipart(Req, [], [], TmpDir),
    Req1 = Req0#{fields => Fields0, files => Files0},
    {ok, Req1, PluginState};
pre_request(Req, _Env, _Options, PluginState) ->
    {ok, Req, PluginState}.

%%%===================================================================
%%% Internal Functions
%%%===================================================================

%%--------------------------------------------------------------------
%% @doc Recursively reads chunks of multipart data stream from Cowboy.
%% @private
%% @end
%%--------------------------------------------------------------------
handle_multipart(Req, Fields, Files, TmpDir) ->
    case cowboy_req:read_part(Req) of
        {ok, Headers, Req0} ->
            case cow_multipart:form_data(Headers) of
                {data, FieldName} ->
                    {ok, Value, Req1} = cowboy_req:read_part_body(Req0),
                    Fields0 = Fields ++ [{uri_string:unquote(FieldName), Value}],
                    handle_multipart(Req1, Fields0, Files, TmpDir);
                {file, FieldName, FileName, ContentType} ->
                    FileName0 = uri_string:quote(FileName),
                    PathBin = binary:encode_hex(crypto:strong_rand_bytes(8)),

                    %% Use dynamically resolved TmpDir instead of hardcoded /tmp
                    TmpPath = filename:join(TmpDir, <<"upload_", PathBin/binary>>),

                    {ok, IoDevice} = file:open(TmpPath, [write, binary, raw]),
                    Req1 = stream_file_body(Req0, IoDevice),
                    file:close(IoDevice),
                    Files0 =
                        Files ++
                            [{uri_string:unquote(FieldName), {FileName0, ContentType, TmpPath}}],

                    %% FIXED BUG: Changed 'Fields' to 'Fields0' to pass accumulated form fields
                    handle_multipart(Req1, Fields, Files0, TmpDir)
            end;
        {done, Req1} ->
            {Req1, Fields, Files}
    end.

%%--------------------------------------------------------------------
%% @doc Streams file contents to disk handling both complete and partial chunks.
%% @private
%% @end
%%--------------------------------------------------------------------
stream_file_body(Req, IoDevice) ->
    case cowboy_req:read_part_body(Req) of
        {ok, LastChunk, Req0} ->
            ok = file:write(IoDevice, LastChunk),
            Req0;
        {more, Chunk, Req0} ->
            ok = file:write(IoDevice, Chunk),
            stream_file_body(Req0, IoDevice)
    end.