| 1 |
|
-module(shurbej_http_files). |
| 2 |
|
-include_lib("shurbej_store/include/shurbej_records.hrl"). |
| 3 |
|
|
| 4 |
|
-export([init/2]). |
| 5 |
|
|
| 6 |
|
init(Req0, State) -> |
| 7 |
53 |
case shurbej_http_common:authorize(Req0) of |
| 8 |
|
{ok, LibRef, _} -> |
| 9 |
52 |
Method = cowboy_req:method(Req0), |
| 10 |
52 |
Perm = case Method of |
| 11 |
42 |
<<"POST">> -> file_write; |
| 12 |
:-( |
<<"PUT">> -> file_write; |
| 13 |
:-( |
<<"DELETE">> -> file_write; |
| 14 |
:-( |
<<"PATCH">> -> file_write; |
| 15 |
10 |
_ -> file_read |
| 16 |
|
end, |
| 17 |
52 |
case shurbej_http_common:check_lib_perm(Perm, LibRef) of |
| 18 |
|
{error, forbidden} -> |
| 19 |
1 |
Req = shurbej_http_common:error_response(403, <<"File access denied">>, Req0), |
| 20 |
1 |
{ok, Req, State}; |
| 21 |
|
ok -> |
| 22 |
51 |
case maps:get(action, State, default) of |
| 23 |
2 |
view -> handle_view(Req0, State); |
| 24 |
2 |
view_url -> handle_view_url(Req0, State); |
| 25 |
47 |
default -> handle(Method, Req0, State) |
| 26 |
|
end |
| 27 |
|
end; |
| 28 |
|
{error, Reason, _} -> |
| 29 |
1 |
Req = shurbej_http_common:auth_error_response(Reason, Req0), |
| 30 |
1 |
{ok, Req, State} |
| 31 |
|
end. |
| 32 |
|
|
| 33 |
|
%% GET — download file by looking up metadata, serving from SHA-256 blob store |
| 34 |
|
handle(<<"GET">>, Req0, State) -> |
| 35 |
6 |
LibRef = shurbej_http_common:lib_ref(Req0), |
| 36 |
6 |
ItemKey = cowboy_req:binding(item_key, Req0), |
| 37 |
6 |
case shurbej_db:get_file_meta(LibRef, ItemKey) of |
| 38 |
|
{ok, #shurbej_file_meta{md5 = Md5, sha256 = Sha256, filename = Filename}} -> |
| 39 |
5 |
BlobFile = shurbej_files:blob_path(Sha256), |
| 40 |
5 |
case filelib:is_regular(BlobFile) of |
| 41 |
|
true -> |
| 42 |
5 |
Req = cowboy_req:reply(200, shurbej_http_common:maybe_backoff(#{ |
| 43 |
|
<<"content-type">> => <<"application/octet-stream">>, |
| 44 |
|
<<"content-disposition">> => <<"attachment; filename=\"", |
| 45 |
|
(shurbej_http_common:sanitize_filename(Filename))/binary, "\"">>, |
| 46 |
|
<<"etag">> => Md5 |
| 47 |
|
}), {sendfile, 0, filelib:file_size(BlobFile), BlobFile}, Req0), |
| 48 |
5 |
{ok, Req, State}; |
| 49 |
|
false -> |
| 50 |
:-( |
Req = shurbej_http_common:error_response(404, <<"File not found on disk">>, Req0), |
| 51 |
:-( |
{ok, Req, State} |
| 52 |
|
end; |
| 53 |
|
undefined -> |
| 54 |
1 |
Req = shurbej_http_common:error_response(404, <<"No file for this item">>, Req0), |
| 55 |
1 |
{ok, Req, State} |
| 56 |
|
end; |
| 57 |
|
|
| 58 |
|
%% POST — upload authorization or file registration |
| 59 |
|
handle(<<"POST">>, Req0, State) -> |
| 60 |
41 |
LibRef = shurbej_http_common:lib_ref(Req0), |
| 61 |
41 |
ItemKey = cowboy_req:binding(item_key, Req0), |
| 62 |
|
%% Form bodies are tiny (a few hundred bytes) — cap to keep slow/huge |
| 63 |
|
%% POSTs from holding request handlers open. |
| 64 |
41 |
case cowboy_req:read_body(Req0, #{length => 65_536, period => 15_000}) of |
| 65 |
|
{ok, Body, Req1} -> |
| 66 |
40 |
handle_post_body(LibRef, ItemKey, Body, Req1, State); |
| 67 |
|
{more, _, Req1} -> |
| 68 |
1 |
Req = shurbej_http_common:error_response(413, |
| 69 |
|
<<"Request body too large">>, Req1), |
| 70 |
1 |
{ok, Req, State} |
| 71 |
|
end; |
| 72 |
|
|
| 73 |
|
handle(_, Req0, State) -> |
| 74 |
:-( |
Req = shurbej_http_common:error_response(405, <<"Method not allowed">>, Req0), |
| 75 |
:-( |
{ok, Req, State}. |
| 76 |
|
|
| 77 |
|
%% Dispatch a POST body — either an upload registration (uploadKey present) |
| 78 |
|
%% or an authorization request (md5 + filename + filesize + mtime). |
| 79 |
|
handle_post_body(LibRef, ItemKey, Body, Req1, State) -> |
| 80 |
40 |
case cowboy_req:header(<<"content-type">>, Req1) of |
| 81 |
|
<<"application/x-www-form-urlencoded", _/binary>> -> |
| 82 |
39 |
Params = cow_qs:parse_qs(Body), |
| 83 |
39 |
Upload = proplists:get_value(<<"upload">>, Params), |
| 84 |
39 |
UploadKeyParam = proplists:get_value(<<"uploadKey">>, Params), |
| 85 |
39 |
Md5 = proplists:get_value(<<"md5">>, Params), |
| 86 |
39 |
case classify_file_post(Upload, UploadKeyParam, Md5) of |
| 87 |
|
{register, UploadKey} -> |
| 88 |
15 |
handle_register(UploadKey, Req1, State); |
| 89 |
|
{authorize, Md5} -> |
| 90 |
23 |
handle_authorize(LibRef, ItemKey, Md5, Params, Req1, State); |
| 91 |
|
_ -> |
| 92 |
1 |
Req = shurbej_http_common:error_response(400, |
| 93 |
|
<<"Missing required parameters">>, Req1), |
| 94 |
1 |
{ok, Req, State} |
| 95 |
|
end; |
| 96 |
|
_ -> |
| 97 |
1 |
Req = shurbej_http_common:error_response(400, |
| 98 |
|
<<"Unsupported content type">>, Req1), |
| 99 |
1 |
{ok, Req, State} |
| 100 |
|
end. |
| 101 |
|
|
| 102 |
|
handle_register(UploadKey, Req1, State) -> |
| 103 |
15 |
case shurbej_files:register_upload(UploadKey) of |
| 104 |
|
{ok, NewVersion} -> |
| 105 |
15 |
Req = cowboy_req:reply(204, shurbej_http_common:maybe_backoff(#{ |
| 106 |
|
<<"last-modified-version">> => integer_to_binary(NewVersion) |
| 107 |
|
}), Req1), |
| 108 |
15 |
{ok, Req, State}; |
| 109 |
|
{error, not_found} -> |
| 110 |
:-( |
Req = shurbej_http_common:error_response(400, |
| 111 |
|
<<"Invalid upload key">>, Req1), |
| 112 |
:-( |
{ok, Req, State}; |
| 113 |
|
{error, not_stored} -> |
| 114 |
:-( |
Req = shurbej_http_common:error_response(400, |
| 115 |
|
<<"File not yet uploaded">>, Req1), |
| 116 |
:-( |
{ok, Req, State}; |
| 117 |
|
{error, precondition_failed} -> |
| 118 |
:-( |
Req = shurbej_http_common:error_response(412, |
| 119 |
|
<<"Library version conflict">>, Req1), |
| 120 |
:-( |
{ok, Req, State} |
| 121 |
|
end. |
| 122 |
|
|
| 123 |
|
handle_authorize(LibRef, ItemKey, Md5, Params, Req1, State) -> |
| 124 |
23 |
case shurbej_http_common:validate_md5(Md5) of |
| 125 |
|
{error, _} -> |
| 126 |
1 |
Req = shurbej_http_common:error_response(400, |
| 127 |
|
<<"Invalid MD5 hash">>, Req1), |
| 128 |
1 |
{ok, Req, State}; |
| 129 |
|
ok -> |
| 130 |
22 |
Filename = shurbej_http_common:sanitize_filename( |
| 131 |
|
proplists:get_value(<<"filename">>, Params, <<"file">>)), |
| 132 |
22 |
Filesize = case shurbej_http_common:safe_int( |
| 133 |
|
proplists:get_value(<<"filesize">>, Params, <<"0">>)) of |
| 134 |
22 |
{ok, FS} -> FS; error -> 0 |
| 135 |
|
end, |
| 136 |
22 |
Mtime = case shurbej_http_common:safe_int( |
| 137 |
|
proplists:get_value(<<"mtime">>, Params, <<"0">>)) of |
| 138 |
22 |
{ok, MT} -> MT; error -> 0 |
| 139 |
|
end, |
| 140 |
22 |
IfNoneMatch = cowboy_req:header(<<"if-none-match">>, Req1), |
| 141 |
22 |
IfMatch = cowboy_req:header(<<"if-match">>, Req1), |
| 142 |
22 |
ExistingMeta = shurbej_db:get_file_meta(LibRef, ItemKey), |
| 143 |
22 |
case check_file_preconditions(IfNoneMatch, IfMatch, ExistingMeta) of |
| 144 |
|
{error, precondition_required} -> |
| 145 |
1 |
Req = shurbej_http_common:error_response(428, |
| 146 |
|
<<"If-None-Match: * or If-Match: <md5> required">>, Req1), |
| 147 |
1 |
{ok, Req, State}; |
| 148 |
|
{error, precondition_failed} -> |
| 149 |
1 |
Req = shurbej_http_common:error_response(412, |
| 150 |
|
<<"File has been modified">>, Req1), |
| 151 |
1 |
{ok, Req, State}; |
| 152 |
|
ok -> |
| 153 |
20 |
case ExistingMeta of |
| 154 |
|
{ok, #shurbej_file_meta{md5 = Md5}} -> |
| 155 |
|
%% Matching MD5: bump version through the gen_server |
| 156 |
|
%% so concurrent exists + registration responses |
| 157 |
|
%% stay ordered. |
| 158 |
4 |
{ok, NewVer} = shurbej_files:confirm_existing(LibRef, ItemKey), |
| 159 |
4 |
Req = shurbej_http_common:json_response(200, |
| 160 |
|
#{<<"exists">> => 1}, NewVer, Req1), |
| 161 |
4 |
{ok, Req, State}; |
| 162 |
|
_ -> |
| 163 |
|
%% New upload — hand back an uploadKey + URL. |
| 164 |
16 |
UploadKey = shurbej_files:prepare_upload(LibRef, ItemKey, #{ |
| 165 |
|
md5 => Md5, filename => Filename, |
| 166 |
|
filesize => Filesize, mtime => Mtime |
| 167 |
|
}), |
| 168 |
16 |
BaseUrl = application:get_env(shurbej, base_url, |
| 169 |
|
<<"http://localhost:8080">>), |
| 170 |
16 |
UploadUrl = iolist_to_binary([BaseUrl, "/upload/", UploadKey]), |
| 171 |
16 |
Req = shurbej_http_common:json_response(200, #{ |
| 172 |
|
<<"url">> => UploadUrl, |
| 173 |
|
<<"contentType">> => <<"application/x-www-form-urlencoded">>, |
| 174 |
|
<<"prefix">> => <<>>, |
| 175 |
|
<<"suffix">> => <<>>, |
| 176 |
|
<<"uploadKey">> => UploadKey |
| 177 |
|
}, Req1), |
| 178 |
16 |
{ok, Req, State} |
| 179 |
|
end |
| 180 |
|
end |
| 181 |
|
end. |
| 182 |
|
|
| 183 |
|
%% GET /items/:item_key/file/view — serve file inline. |
| 184 |
|
%% Attacker-supplied HTML/SVG attachments would otherwise execute in the |
| 185 |
|
%% app origin. `Content-Security-Policy: sandbox` forces a unique origin so |
| 186 |
|
%% scripts can't read session cookies or localStorage; `nosniff` stops |
| 187 |
|
%% browsers from re-guessing the content type. |
| 188 |
|
handle_view(Req0, State) -> |
| 189 |
2 |
case cowboy_req:method(Req0) of |
| 190 |
|
<<"GET">> -> |
| 191 |
2 |
LibRef = shurbej_http_common:lib_ref(Req0), |
| 192 |
2 |
ItemKey = cowboy_req:binding(item_key, Req0), |
| 193 |
2 |
case shurbej_db:get_file_meta(LibRef, ItemKey) of |
| 194 |
|
{ok, #shurbej_file_meta{sha256 = Sha256, filename = Filename}} -> |
| 195 |
1 |
BlobFile = shurbej_files:blob_path(Sha256), |
| 196 |
1 |
case filelib:is_regular(BlobFile) of |
| 197 |
|
true -> |
| 198 |
1 |
ContentType = guess_content_type(Filename), |
| 199 |
1 |
SafeName = shurbej_http_common:sanitize_filename(Filename), |
| 200 |
1 |
Req = cowboy_req:reply(200, shurbej_http_common:maybe_backoff(#{ |
| 201 |
|
<<"content-type">> => ContentType, |
| 202 |
|
<<"content-disposition">> => |
| 203 |
|
<<"inline; filename=\"", SafeName/binary, "\"">>, |
| 204 |
|
<<"content-security-policy">> => <<"sandbox">>, |
| 205 |
|
<<"x-content-type-options">> => <<"nosniff">> |
| 206 |
|
}), {sendfile, 0, filelib:file_size(BlobFile), BlobFile}, Req0), |
| 207 |
1 |
{ok, Req, State}; |
| 208 |
|
false -> |
| 209 |
:-( |
Req = shurbej_http_common:error_response(404, |
| 210 |
|
<<"File not found on disk">>, Req0), |
| 211 |
:-( |
{ok, Req, State} |
| 212 |
|
end; |
| 213 |
|
undefined -> |
| 214 |
1 |
Req = shurbej_http_common:error_response(404, |
| 215 |
|
<<"No file for this item">>, Req0), |
| 216 |
1 |
{ok, Req, State} |
| 217 |
|
end; |
| 218 |
|
_ -> |
| 219 |
:-( |
Req = shurbej_http_common:error_response(405, <<"Method not allowed">>, Req0), |
| 220 |
:-( |
{ok, Req, State} |
| 221 |
|
end. |
| 222 |
|
|
| 223 |
|
%% GET /items/:item_key/file/view/url — return URL to the file |
| 224 |
|
handle_view_url(Req0, State) -> |
| 225 |
2 |
case cowboy_req:method(Req0) of |
| 226 |
|
<<"GET">> -> |
| 227 |
2 |
LibRef = shurbej_http_common:lib_ref(Req0), |
| 228 |
2 |
ItemKey = cowboy_req:binding(item_key, Req0), |
| 229 |
2 |
case shurbej_db:get_file_meta(LibRef, ItemKey) of |
| 230 |
|
{ok, _} -> |
| 231 |
1 |
Base = shurbej_http_common:base_url(), |
| 232 |
1 |
Prefix = shurbej_http_common:lib_path_prefix(LibRef), |
| 233 |
1 |
Url = <<Base/binary, Prefix/binary, |
| 234 |
|
"/items/", ItemKey/binary, "/file/view">>, |
| 235 |
1 |
Req = shurbej_http_common:json_response(200, #{<<"url">> => Url}, Req0), |
| 236 |
1 |
{ok, Req, State}; |
| 237 |
|
undefined -> |
| 238 |
1 |
Req = shurbej_http_common:error_response(404, |
| 239 |
|
<<"No file for this item">>, Req0), |
| 240 |
1 |
{ok, Req, State} |
| 241 |
|
end; |
| 242 |
|
_ -> |
| 243 |
:-( |
Req = shurbej_http_common:error_response(405, <<"Method not allowed">>, Req0), |
| 244 |
:-( |
{ok, Req, State} |
| 245 |
|
end. |
| 246 |
|
|
| 247 |
|
%% Check If-None-Match / If-Match preconditions for file uploads. |
| 248 |
|
check_file_preconditions(<<"*">>, _, undefined) -> |
| 249 |
16 |
ok; %% If-None-Match: * and no existing file — new upload |
| 250 |
|
check_file_preconditions(<<"*">>, _, {ok, _}) -> |
| 251 |
1 |
{error, precondition_failed}; %% If-None-Match: * but file exists |
| 252 |
|
check_file_preconditions(_, IfMatch, {ok, #shurbej_file_meta{md5 = ExistingMd5}}) |
| 253 |
|
when is_binary(IfMatch) -> |
| 254 |
4 |
case IfMatch =:= ExistingMd5 of |
| 255 |
4 |
true -> ok; |
| 256 |
:-( |
false -> {error, precondition_failed} |
| 257 |
|
end; |
| 258 |
|
check_file_preconditions(_, IfMatch, undefined) when is_binary(IfMatch) -> |
| 259 |
:-( |
{error, precondition_failed}; %% If-Match but no existing file |
| 260 |
|
check_file_preconditions(undefined, undefined, _) -> |
| 261 |
1 |
{error, precondition_required}. |
| 262 |
|
|
| 263 |
|
%% Classify a POST to /file as authorization or registration. |
| 264 |
|
%% Zotero uses: upload=<key> for registration, md5+filename+filesize for authorization. |
| 265 |
|
%% The "upload" param is "1" for auth in some clients, or the upload key for registration. |
| 266 |
|
classify_file_post(_Upload, UploadKey, _Md5) when UploadKey =/= undefined -> |
| 267 |
15 |
{register, UploadKey}; |
| 268 |
|
classify_file_post(Upload, _, _Md5) when Upload =/= undefined, Upload =/= <<"1">> -> |
| 269 |
|
%% upload=<hex_key> — this is registration (Zotero sends upload=<uploadKey>) |
| 270 |
:-( |
{register, Upload}; |
| 271 |
|
classify_file_post(_, _, Md5) when Md5 =/= undefined -> |
| 272 |
23 |
{authorize, Md5}; |
| 273 |
|
classify_file_post(<<"1">>, _, _) -> |
| 274 |
|
%% upload=1 with no md5 — malformed auth request |
| 275 |
:-( |
bad_request; |
| 276 |
|
classify_file_post(_, _, _) -> |
| 277 |
1 |
bad_request. |
| 278 |
|
|
| 279 |
|
guess_content_type(Filename) -> |
| 280 |
1 |
case filename:extension(string:lowercase(Filename)) of |
| 281 |
:-( |
<<".pdf">> -> <<"application/pdf">>; |
| 282 |
:-( |
<<".html">> -> <<"text/html">>; |
| 283 |
:-( |
<<".htm">> -> <<"text/html">>; |
| 284 |
1 |
<<".txt">> -> <<"text/plain">>; |
| 285 |
:-( |
<<".png">> -> <<"image/png">>; |
| 286 |
:-( |
<<".jpg">> -> <<"image/jpeg">>; |
| 287 |
:-( |
<<".jpeg">> -> <<"image/jpeg">>; |
| 288 |
:-( |
<<".gif">> -> <<"image/gif">>; |
| 289 |
:-( |
<<".svg">> -> <<"image/svg+xml">>; |
| 290 |
:-( |
<<".epub">> -> <<"application/epub+zip">>; |
| 291 |
:-( |
_ -> <<"application/octet-stream">> |
| 292 |
|
end. |