%%%-------------------------------------------------------------------
%%% @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.