/__w/shurbej/shurbej/_build/test/cover/aggregate/shurbej_http_files.html

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.
Line Hits Source