| 1 |
|
-module(shurbej_http_upload). |
| 2 |
|
-export([init/2]). |
| 3 |
|
|
| 4 |
|
%% Handles the actual file upload at /upload/:upload_key. |
| 5 |
|
%% Zotero may send as application/x-www-form-urlencoded (url-encoded bytes), |
| 6 |
|
%% multipart/form-data (file in a "file" part), or raw octet-stream. |
| 7 |
|
init(Req0, State) -> |
| 8 |
18 |
case cowboy_req:method(Req0) of |
| 9 |
|
<<"POST">> -> |
| 10 |
17 |
UploadKey = cowboy_req:binding(upload_key, Req0), |
| 11 |
17 |
case shurbej_files:get_pending(UploadKey) of |
| 12 |
|
{ok, Meta} -> |
| 13 |
16 |
case read_file_data(Req0) of |
| 14 |
|
{ok, Body, Req1} -> |
| 15 |
16 |
handle_store(UploadKey, Meta, Body, Req1, State); |
| 16 |
|
{error, too_large, Req1} -> |
| 17 |
:-( |
Req = shurbej_http_common:error_response(413, |
| 18 |
|
<<"Uploaded file exceeds max size">>, Req1), |
| 19 |
:-( |
{ok, Req, State} |
| 20 |
|
end; |
| 21 |
|
{error, not_found} -> |
| 22 |
1 |
Req = shurbej_http_common:error_response(404, <<"Unknown upload key">>, Req0), |
| 23 |
1 |
{ok, Req, State} |
| 24 |
|
end; |
| 25 |
|
_ -> |
| 26 |
1 |
Req = shurbej_http_common:error_response(405, <<"Method not allowed">>, Req0), |
| 27 |
1 |
{ok, Req, State} |
| 28 |
|
end. |
| 29 |
|
|
| 30 |
|
handle_store(UploadKey, Meta, Body, Req1, State) -> |
| 31 |
16 |
case shurbej_files:store(UploadKey, Meta, Body) of |
| 32 |
|
ok -> |
| 33 |
15 |
Req = cowboy_req:reply(201, #{}, <<>>, Req1), |
| 34 |
15 |
{ok, Req, State}; |
| 35 |
|
{error, md5_mismatch} -> |
| 36 |
1 |
Req = shurbej_http_common:error_response(412, |
| 37 |
|
<<"Uploaded file MD5 does not match expected hash">>, Req1), |
| 38 |
1 |
{ok, Req, State}; |
| 39 |
|
{error, zip_too_large} -> |
| 40 |
:-( |
Req = shurbej_http_common:error_response(413, |
| 41 |
|
<<"Decompressed upload exceeds max size">>, Req1), |
| 42 |
:-( |
{ok, Req, State}; |
| 43 |
|
{error, Reason} -> |
| 44 |
:-( |
logger:error("File storage error: ~p", [Reason]), |
| 45 |
:-( |
Req = shurbej_http_common:error_response(500, |
| 46 |
|
<<"File storage error">>, Req1), |
| 47 |
:-( |
{ok, Req, State} |
| 48 |
|
end. |
| 49 |
|
|
| 50 |
|
%% Read file data, handling different content-types. |
| 51 |
|
%% Zotero sends raw bytes with Content-Type: application/x-www-form-urlencoded |
| 52 |
|
%% (not actually url-encoded) when prefix/suffix are empty. |
| 53 |
|
%% For multipart, extract the "file" part. |
| 54 |
|
read_file_data(Req) -> |
| 55 |
16 |
Max = max_upload_bytes(), |
| 56 |
16 |
case cowboy_req:header(<<"content-type">>, Req) of |
| 57 |
|
<<"multipart/form-data", _/binary>> -> |
| 58 |
1 |
read_multipart_file(Req, Max); |
| 59 |
|
_ -> |
| 60 |
15 |
read_full_body(Req, [], 0, Max) |
| 61 |
|
end. |
| 62 |
|
|
| 63 |
|
%% Read multipart body, extract the "file" part. |
| 64 |
|
read_multipart_file(Req0, Max) -> |
| 65 |
1 |
case cowboy_req:read_part(Req0) of |
| 66 |
|
{ok, Headers, Req1} -> |
| 67 |
1 |
case cow_multipart:form_data(Headers) of |
| 68 |
|
{file, <<"file">>, _Filename, _CT} -> |
| 69 |
1 |
read_part_body(Req1, [], 0, Max); |
| 70 |
|
_ -> |
| 71 |
|
%% Skip non-file parts; still enforce the cap so an |
| 72 |
|
%% attacker can't stream unbounded data in a junk part. |
| 73 |
:-( |
case read_part_body(Req1, [], 0, Max) of |
| 74 |
:-( |
{ok, _Skip, Req2} -> read_multipart_file(Req2, Max); |
| 75 |
:-( |
{error, _, _} = Err -> Err |
| 76 |
|
end |
| 77 |
|
end; |
| 78 |
|
{done, Req1} -> |
| 79 |
:-( |
{ok, <<>>, Req1} |
| 80 |
|
end. |
| 81 |
|
|
| 82 |
|
%% Accumulate chunks, flatten once at the end. Reject if total exceeds Max. |
| 83 |
|
read_part_body(Req0, Acc, Size, Max) -> |
| 84 |
1 |
case cowboy_req:read_part_body(Req0, #{length => 8_000_000, period => 30000}) of |
| 85 |
|
{ok, Body, Req} -> |
| 86 |
1 |
NewSize = Size + byte_size(Body), |
| 87 |
1 |
case NewSize > Max of |
| 88 |
:-( |
true -> {error, too_large, Req}; |
| 89 |
1 |
false -> {ok, iolist_to_binary(lists:reverse([Body | Acc])), Req} |
| 90 |
|
end; |
| 91 |
|
{more, Body, Req} -> |
| 92 |
:-( |
NewSize = Size + byte_size(Body), |
| 93 |
:-( |
case NewSize > Max of |
| 94 |
:-( |
true -> {error, too_large, Req}; |
| 95 |
:-( |
false -> read_part_body(Req, [Body | Acc], NewSize, Max) |
| 96 |
|
end |
| 97 |
|
end. |
| 98 |
|
|
| 99 |
|
read_full_body(Req0, Acc, Size, Max) -> |
| 100 |
15 |
case cowboy_req:read_body(Req0, #{length => 8_000_000, period => 30000}) of |
| 101 |
|
{ok, Body, Req} -> |
| 102 |
15 |
NewSize = Size + byte_size(Body), |
| 103 |
15 |
case NewSize > Max of |
| 104 |
:-( |
true -> {error, too_large, Req}; |
| 105 |
15 |
false -> {ok, iolist_to_binary(lists:reverse([Body | Acc])), Req} |
| 106 |
|
end; |
| 107 |
|
{more, Body, Req} -> |
| 108 |
:-( |
NewSize = Size + byte_size(Body), |
| 109 |
:-( |
case NewSize > Max of |
| 110 |
:-( |
true -> {error, too_large, Req}; |
| 111 |
:-( |
false -> read_full_body(Req, [Body | Acc], NewSize, Max) |
| 112 |
|
end |
| 113 |
|
end. |
| 114 |
|
|
| 115 |
|
max_upload_bytes() -> |
| 116 |
16 |
application:get_env(shurbej, max_upload_bytes, 100 * 1024 * 1024). |