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

1 -module(shurbej_http_common).
2 -include_lib("shurbej_store/include/shurbej_records.hrl").
3
4 -export([
5 extract_api_key/1,
6 authenticate/1,
7 authorize/1,
8 check_perm/1,
9 check_lib_perm/2,
10 normalize_perms/1,
11 lib_ref/1,
12 lib_path_prefix/1,
13 get_since/1,
14 get_format/1,
15 get_if_unmodified/1,
16 get_if_modified/1,
17 get_item_keys/1,
18 get_collection_keys/1,
19 get_search_keys/1,
20 get_tag_filter/1,
21 get_item_type_filter/1,
22 get_query/1,
23 get_qmode/1,
24 get_include_trashed/1,
25 get_write_token/1,
26 get_limit/1,
27 get_start/1,
28 get_sort/1,
29 get_direction/1,
30 check_304/2,
31 filter_by_keys/2,
32 filter_by_tag/2,
33 filter_by_item_type/2,
34 filter_by_query/3,
35 json_response/3,
36 json_response/4,
37 error_response/3,
38 paginate/3,
39 sort_records/3,
40 list_response/4,
41 read_json_body/1,
42 safe_int/1,
43 sanitize_filename/1,
44 validate_md5/1,
45 base_url/0,
46 library_obj/1,
47 envelope_item/2,
48 envelope_item/3,
49 envelope_collection/2,
50 envelope_search/2,
51 envelope_group/1,
52 auth_error_response/2,
53 maybe_backoff/1
54 ]).
55
56 -define(MAX_BODY_SIZE, 8_000_000). %% 8MB
57 -define(MAX_DECOMPRESSED_SIZE, 32_000_000). %% 32MB — cap for gzip expansion
58
59 %% ===================================================================
60 %% Authentication & Authorization
61 %% ===================================================================
62
63 %% Extract API key from request (header or query param).
64 extract_api_key(Req) ->
65 296 case cowboy_req:header(<<"zotero-api-key">>, Req) of
66 undefined ->
67 17 case cowboy_req:header(<<"authorization">>, Req) of
68
:-(
<<"Bearer ", Key/binary>> -> Key;
69 _ ->
70 17 #{key := Key} = cowboy_req:match_qs([{key, [], undefined}], Req),
71 17 Key
72 end;
73 Key ->
74 279 Key
75 end.
76
77 %% Authenticate only — verify the API key is valid, stash permissions.
78 authenticate(Req) ->
79 273 case extract_api_key(Req) of
80 undefined ->
81 9 {error, Req};
82 Key ->
83 264 case shurbej_auth:key_info(Key) of
84 {ok, #{user_id := UserId, permissions := Perms}} ->
85 264 put(shurbej_perms, normalize_perms(Perms)),
86 264 {ok, UserId, Req};
87
:-(
{error, _} -> {error, Req}
88 end
89 end.
90
91 %% Authenticate AND authorize — verify API key AND that the authenticated
92 %% user has access to the library specified in the URL path.
93 %% Returns {ok, LibRef, Req} where LibRef is the library being accessed,
94 %% or the authenticated user's own library when no library scope is in URL.
95 authorize(Req) ->
96 273 case authenticate(Req) of
97 {ok, UserId, Req2} ->
98 264 case shurbej_rate_limit:check(UserId) of
99 {error, rate_limited, Retry} ->
100 2 {error, {rate_limited, Retry}, Req2};
101 {backoff, Secs} ->
102 2 put(shurbej_backoff, Secs),
103 2 authorize_path(UserId, Req2);
104 ok ->
105 260 authorize_path(UserId, Req2)
106 end;
107 {error, Req2} ->
108 9 {error, forbidden, Req2}
109 end.
110
111 authorize_path(UserId, Req) ->
112 262 case cowboy_req:binding(group_id, Req) of
113 undefined ->
114 248 case cowboy_req:binding(user_id, Req) of
115 undefined ->
116 %% Endpoints without any library binding (e.g., /schema).
117
:-(
{ok, {user, UserId}, Req};
118 UserIdBin ->
119 248 case safe_int(UserIdBin) of
120 248 {ok, UserId} -> {ok, {user, UserId}, Req};
121
:-(
{ok, _Other} -> {error, forbidden, Req};
122
:-(
error -> {error, bad_request, Req}
123 end
124 end;
125 GroupIdBin ->
126 14 case safe_int(GroupIdBin) of
127 {ok, GroupId} ->
128 14 case shurbej_db:get_group(GroupId) of
129 undefined ->
130 1 {error, forbidden, Req};
131 {ok, _} ->
132 %% Stash caller's role (or `none` if not a member)
133 %% so check_lib_perm/2 can decide without re-reading.
134 13 Role = case shurbej_db:get_group_member(GroupId, UserId) of
135 10 {ok, #shurbej_group_member{role = R}} -> R;
136 3 undefined -> none
137 end,
138 13 put(shurbej_group_role, Role),
139 13 {ok, {group, GroupId}, Req}
140 end;
141
:-(
error -> {error, bad_request, Req}
142 end
143 end.
144
145 %% Get the LibRef from the URL path bindings (safe).
146 %% Returns {user, UserId} | {group, GroupId}.
147 lib_ref(Req) ->
148 246 case cowboy_req:binding(group_id, Req) of
149 undefined ->
150 239 {ok, UserId} = safe_int(cowboy_req:binding(user_id, Req)),
151 239 {user, UserId};
152 GroupIdBin ->
153 7 {ok, GroupId} = safe_int(GroupIdBin),
154 7 {group, GroupId}
155 end.
156
157 %% URL path prefix for a library (e.g. /users/1 or /groups/42).
158 lib_path_prefix({user, Id}) ->
159 534 <<"/users/", (integer_to_binary(Id))/binary>>;
160 lib_path_prefix({group, Id}) ->
161 3 <<"/groups/", (integer_to_binary(Id))/binary>>.
162
163 %% ===================================================================
164 %% Permissions
165 %% ===================================================================
166
167 %% Normalize a stored permission map to the canonical Zotero form:
168 %% #{user => #{library, write, files, notes},
169 %% groups => #{all => #{library, write}}
170 %% | #{GroupId (int) => #{library, write}, all => ...}}.
171 %%
172 %% `undefined` (and other non-map inputs) yields full access — convenient for
173 %% admin helpers that want an "all access" key without constructing the map.
174 normalize_perms(Perms) when is_map(Perms) ->
175 278 #{
176 user => normalize_user_bucket(maps:get(user, Perms, #{})),
177 groups => normalize_groups_bucket(maps:get(groups, Perms, #{}))
178 };
179 normalize_perms(_) ->
180 41 all_access().
181
182 all_access() ->
183 41 #{
184 user => #{library => true, write => true, files => true, notes => true},
185 groups => #{all => #{library => true, write => true}}
186 }.
187
188 normalize_user_bucket(M) when is_map(M) ->
189 278 #{library => truthy(maps:get(library, M, false)),
190 write => truthy(maps:get(write, M, false)),
191 files => truthy(maps:get(files, M, false)),
192 notes => truthy(maps:get(notes, M, false))};
193 normalize_user_bucket(_) ->
194
:-(
#{library => false, write => false, files => false, notes => false}.
195
196 normalize_groups_bucket(M) when is_map(M) ->
197 278 maps:fold(fun(K, V, Acc) ->
198 276 NormK = normalize_group_key(K),
199 276 Acc#{NormK => #{
200 library => truthy(maps:get(library, V, false)),
201 write => truthy(maps:get(write, V, false))
202 }}
203 end, #{}, M);
204
:-(
normalize_groups_bucket(_) -> #{}.
205
206 275 normalize_group_key(all) -> all;
207 1 normalize_group_key(<<"all">>) -> all;
208
:-(
normalize_group_key(N) when is_integer(N) -> N;
209 normalize_group_key(B) when is_binary(B) ->
210
:-(
case safe_int(B) of
211
:-(
{ok, N} -> N;
212
:-(
error -> B
213 end;
214
:-(
normalize_group_key(K) -> K.
215
216 1640 truthy(true) -> true;
217 24 truthy(_) -> false.
218
219 %% Check an unscoped permission (user bucket only). For library-scoped ops
220 %% prefer check_lib_perm/2 which also honours group role + group policy.
221 check_perm(Perm) ->
222
:-(
case get(shurbej_perms) of
223
:-(
#{user := #{Perm := true}} -> ok;
224
:-(
_ -> {error, forbidden}
225 end.
226
227 %% Library-scoped permission check.
228 %% Perm :: read | write | file_read | file_write.
229 %% read — list/get metadata (user.library / group library_reading)
230 %% write — create/update/delete metadata (user.write / group library_editing)
231 %% file_read — download a file (user.files / group library_reading)
232 %% file_write — upload/replace/delete a file (user.files / group file_editing)
233 %%
234 %% For user libraries, gates on the user bucket alone. For group libraries,
235 %% combines the stored group-key grant with the group's own policy and the
236 %% caller's role (cached from authorize/1).
237 check_lib_perm(Perm, {user, _}) ->
238 243 UserKey = user_key_for(Perm),
239 243 case get(shurbej_perms) of
240 242 #{user := #{UserKey := true}} -> ok;
241 1 _ -> {error, forbidden}
242 end;
243 check_lib_perm(Perm, {group, GroupId}) ->
244 13 case shurbej_db:get_group(GroupId) of
245
:-(
undefined -> {error, forbidden};
246 {ok, Group} ->
247 13 Role = case get(shurbej_group_role) of
248
:-(
undefined -> none;
249 13 R -> R
250 end,
251 13 case group_rule_allows(Perm, Group, Role)
252 9 andalso group_key_allows(Perm, GroupId) of
253 8 true -> ok;
254 5 false -> {error, forbidden}
255 end
256 end.
257
258 92 user_key_for(read) -> library;
259 102 user_key_for(write) -> write;
260 10 user_key_for(file_read) -> files;
261 39 user_key_for(file_write) -> files.
262
263 %% Group policy rule — does the group's own setting + caller's role allow this?
264 %% read / file_read → library_reading
265 %% write → library_editing
266 %% file_write → file_editing
267 group_rule_allows(Perm, #shurbej_group{library_reading = all}, _Role)
268 1 when Perm =:= read; Perm =:= file_read -> true;
269 group_rule_allows(Perm, _Group, none)
270 2 when Perm =:= read; Perm =:= file_read -> false;
271 group_rule_allows(Perm, _Group, _Role)
272 3 when Perm =:= read; Perm =:= file_read -> true;
273 group_rule_allows(write, #shurbej_group{library_editing = members}, Role)
274 2 when Role =/= none -> true;
275 group_rule_allows(write, #shurbej_group{library_editing = admins}, Role)
276 1 when Role =:= owner; Role =:= admin -> true;
277 1 group_rule_allows(write, _, _) -> false;
278 1 group_rule_allows(file_write, #shurbej_group{file_editing = none}, _) -> false;
279 group_rule_allows(file_write, #shurbej_group{file_editing = members}, Role)
280 2 when Role =/= none -> true;
281 group_rule_allows(file_write, #shurbej_group{file_editing = admins}, Role)
282
:-(
when Role =:= owner; Role =:= admin -> true;
283
:-(
group_rule_allows(file_write, _, _) -> false.
284
285 %% Key-grant rule — does the API key carry the right per-group permission?
286 %% Reads (including file_read) use the group-key `library` bit;
287 %% writes (including file_write) use the group-key `write` bit.
288 group_key_allows(Perm, GroupId) ->
289 9 KeyPerm = case Perm of
290 4 read -> library;
291
:-(
file_read -> library;
292 5 _ -> write
293 end,
294 9 case get(shurbej_perms) of
295 #{groups := Groups} when is_map(Groups) ->
296 9 has_group_grant(KeyPerm, GroupId, Groups);
297
:-(
_ -> false
298 end.
299
300 has_group_grant(KeyPerm, GroupId, Groups) ->
301 9 case maps:get(GroupId, Groups, undefined) of
302
:-(
#{KeyPerm := true} -> true;
303 _ ->
304 9 case maps:get(all, Groups, undefined) of
305 8 #{KeyPerm := true} -> true;
306 1 _ -> false
307 end
308 end.
309
310 %% ===================================================================
311 %% Safe input parsing
312 %% ===================================================================
313
314 safe_int(Bin) when is_binary(Bin) ->
315 744 try {ok, binary_to_integer(Bin)}
316
:-(
catch error:badarg -> error
317 end;
318
:-(
safe_int(_) -> error.
319
320 %% Read and decode a JSON body with size limit.
321 %% Returns {ok, Term, Req} | {error, Reason, Req}.
322 read_json_body(Req) ->
323 115 case cowboy_req:read_body(Req, #{length => ?MAX_BODY_SIZE, period => 15000}) of
324 {ok, Body, Req2} ->
325 115 case maybe_decompress(Body, Req2) of
326
:-(
{error, Reason} -> {error, Reason, Req2};
327 Decoded ->
328 115 try {ok, simdjson:decode(Decoded), Req2}
329 2 catch _:_ -> {error, invalid_json, Req2}
330 end
331 end;
332 {more, _, Req2} ->
333
:-(
{error, body_too_large, Req2}
334 end.
335
336 %% Validate MD5 is exactly 32 lowercase hex characters.
337 validate_md5(Md5) when is_binary(Md5), byte_size(Md5) =:= 32 ->
338 22 case re:run(Md5, <<"^[0-9a-f]{32}$">>) of
339 22 {match, _} -> ok;
340
:-(
nomatch -> {error, invalid_md5}
341 end;
342 1 validate_md5(_) -> {error, invalid_md5}.
343
344 %% Sanitize filename for Content-Disposition header.
345 sanitize_filename(Filename) ->
346 %% Remove characters that could break Content-Disposition or allow CRLF injection
347 28 Clean = binary:replace(
348 binary:replace(
349 binary:replace(
350 binary:replace(Filename, <<"\"">>, <<>>, [global]),
351 <<"\r">>, <<>>, [global]),
352 <<"\n">>, <<>>, [global]),
353 <<"\0">>, <<>>, [global]),
354 28 case byte_size(Clean) of
355
:-(
0 -> <<"file">>;
356 28 _ -> Clean
357 end.
358
359 %% ===================================================================
360 %% Query parameter parsers (all safe — no crashes on bad input)
361 %% ===================================================================
362
363 get_since(Req) ->
364 78 #{since := Since} = cowboy_req:match_qs([{since, [], <<"0">>}], Req),
365 78 case safe_int(Since) of {ok, N} -> max(0, N); error -> 0 end.
366
367 get_format(Req) ->
368 72 #{format := Format} = cowboy_req:match_qs([{format, [], <<"json">>}], Req),
369 72 Format.
370
371 get_if_unmodified(Req) ->
372 101 case cowboy_req:header(<<"if-unmodified-since-version">>, Req) of
373 81 undefined -> any;
374 20 V -> case safe_int(V) of {ok, N} -> N; error -> any end
375 end.
376
377 get_if_modified(Req) ->
378 81 case cowboy_req:header(<<"if-modified-since-version">>, Req) of
379 75 undefined -> undefined;
380 6 V -> case safe_int(V) of {ok, N} -> N; error -> undefined end
381 end.
382
383 get_item_keys(Req) ->
384 46 #{itemKey := P} = cowboy_req:match_qs([{itemKey, [], <<>>}], Req),
385 46 case P of <<>> -> all; _ -> binary:split(P, <<",">>, [global]) end.
386
387 get_collection_keys(Req) ->
388 6 #{collectionKey := P} = cowboy_req:match_qs([{collectionKey, [], <<>>}], Req),
389 6 case P of <<>> -> all; _ -> binary:split(P, <<",">>, [global]) end.
390
391 get_search_keys(Req) ->
392 6 #{searchKey := P} = cowboy_req:match_qs([{searchKey, [], <<>>}], Req),
393 6 case P of <<>> -> all; _ -> binary:split(P, <<",">>, [global]) end.
394
395 get_tag_filter(Req) ->
396 46 #{tag := T} = cowboy_req:match_qs([{tag, [], <<>>}], Req),
397 46 case T of <<>> -> none; _ -> T end.
398
399 get_item_type_filter(Req) ->
400 46 #{itemType := T} = cowboy_req:match_qs([{itemType, [], <<>>}], Req),
401 46 case T of <<>> -> none; _ -> T end.
402
403 get_query(Req) ->
404 46 #{q := Q} = cowboy_req:match_qs([{q, [], <<>>}], Req),
405 46 case Q of <<>> -> none; _ -> Q end.
406
407 get_qmode(Req) ->
408 46 #{qmode := Mode} = cowboy_req:match_qs([{qmode, [], <<"titleCreatorYear">>}], Req),
409 46 Mode.
410
411 get_include_trashed(Req) ->
412 44 #{includeTrashed := V} = cowboy_req:match_qs([{includeTrashed, [], <<"0">>}], Req),
413 44 V =:= <<"1">> orelse V =:= <<"true">>.
414
415 get_write_token(Req) ->
416 62 cowboy_req:header(<<"zotero-write-token">>, Req).
417
418 get_limit(Req) ->
419 44 #{limit := Limit} = cowboy_req:match_qs([{limit, [], <<"25">>}], Req),
420 44 case safe_int(Limit) of {ok, N} -> min(max(1, N), 100); error -> 25 end.
421
422 get_start(Req) ->
423 44 #{start := Start} = cowboy_req:match_qs([{start, [], <<"0">>}], Req),
424 44 case safe_int(Start) of {ok, N} -> max(0, N); error -> 0 end.
425
426 get_sort(Req) ->
427 41 #{sort := Sort} = cowboy_req:match_qs([{sort, [], <<"dateModified">>}], Req),
428 41 Sort.
429
430 get_direction(Req) ->
431 41 #{direction := Dir} = cowboy_req:match_qs([{direction, [], <<"desc">>}], Req),
432 41 Dir.
433
434 %% ===================================================================
435 %% Pagination & sorting
436 %% ===================================================================
437
438 paginate(Items, Start, Limit) ->
439 44 Total = length(Items),
440 44 Safe = max(0, min(Start, Total)),
441 44 Page = lists:sublist(lists:nthtail(Safe, Items), Limit),
442 44 {Page, Total}.
443
444 sort_records(Records, SortField, Direction) ->
445 41 ExtractFn = sort_key_fn(SortField),
446 41 lists:sort(fun(A, B) ->
447 766 KA = ExtractFn(A),
448 766 KB = ExtractFn(B),
449 766 case Direction of
450 87 <<"asc">> -> KA =< KB;
451 679 _ -> KA >= KB
452 end
453 end, Records).
454
455 sort_key_fn(<<"dateModified">>) ->
456 39 fun(#shurbej_item{data = D}) -> maps:get(<<"dateModified">>, D, <<>>);
457
:-(
(#shurbej_collection{data = D}) -> maps:get(<<"dateModified">>, D, <<>>);
458
:-(
(#shurbej_search{data = D}) -> maps:get(<<"dateModified">>, D, <<>>)
459 end;
460 sort_key_fn(<<"dateAdded">>) ->
461
:-(
fun(#shurbej_item{data = D}) -> maps:get(<<"dateAdded">>, D, <<>>);
462
:-(
(#shurbej_collection{data = D}) -> maps:get(<<"dateAdded">>, D, <<>>);
463
:-(
(#shurbej_search{data = D}) -> maps:get(<<"dateAdded">>, D, <<>>)
464 end;
465 sort_key_fn(<<"title">>) ->
466 2 fun(#shurbej_item{data = D}) -> maps:get(<<"title">>, D, <<>>);
467
:-(
(#shurbej_collection{data = D}) -> maps:get(<<"name">>, D, <<>>);
468
:-(
(#shurbej_search{data = D}) -> maps:get(<<"name">>, D, <<>>)
469 end;
470 sort_key_fn(<<"creator">>) ->
471
:-(
fun(#shurbej_item{data = D}) ->
472
:-(
case maps:get(<<"creators">>, D, []) of
473
:-(
[C | _] -> maps:get(<<"lastName">>, C, maps:get(<<"name">>, C, <<>>));
474
:-(
_ -> <<>>
475 end;
476
:-(
(_) -> <<>>
477 end;
478 sort_key_fn(<<"itemType">>) ->
479
:-(
fun(#shurbej_item{data = D}) -> maps:get(<<"itemType">>, D, <<>>);
480
:-(
(_) -> <<>>
481 end;
482 sort_key_fn(_) ->
483
:-(
fun(#shurbej_item{version = V}) -> V;
484
:-(
(#shurbej_collection{version = V}) -> V;
485
:-(
(#shurbej_search{version = V}) -> V
486 end.
487
488 %% Build a paginated list response with Total-Results and Link headers.
489 list_response(Req, Items, LibVersion, EnvelopeFn) ->
490 44 Start = get_start(Req),
491 44 Limit = get_limit(Req),
492 44 {Page, Total} = paginate(Items, Start, Limit),
493 44 Enveloped = [EnvelopeFn(I) || I <- Page],
494 44 Headers = #{
495 <<"content-type">> => <<"application/json">>,
496 <<"last-modified-version">> => integer_to_binary(LibVersion),
497 <<"total-results">> => integer_to_binary(Total),
498 <<"zotero-api-version">> => <<"3">>
499 },
500 44 Headers2 = add_link_headers(Headers, Req, Start, Limit, Total),
501 44 cowboy_req:reply(200, maybe_backoff(Headers2), simdjson:encode(Enveloped), Req).
502
503 add_link_headers(Headers, Req, Start, Limit, Total) ->
504 44 Base = page_base_url(Req),
505 44 Links = lists:flatten([
506 11 [page_link(Base, Start + Limit, Limit, <<"next">>) || Start + Limit < Total],
507 1 [page_link(Base, max(0, Start - Limit), Limit, <<"prev">>) || Start > 0]
508 ]),
509 44 case Links of
510 33 [] -> Headers;
511 11 _ -> Headers#{<<"link">> => iolist_to_binary(lists:join(<<", ">>, Links))}
512 end.
513
514 page_base_url(Req) ->
515 44 Path = cowboy_req:path(Req),
516 44 QS = strip_qs_params(cowboy_req:qs(Req), [<<"start">>, <<"limit">>]),
517 44 iolist_to_binary([Path, <<"?">>, QS]).
518
519 page_link(Base, Start, Limit, Rel) ->
520 12 <<Base/binary, "&start=", (integer_to_binary(Start))/binary,
521 "&limit=", (integer_to_binary(Limit))/binary,
522 "; rel=\"", Rel/binary, "\"">>.
523
524 strip_qs_params(QS, Remove) ->
525 44 Params = cow_qs:parse_qs(QS),
526 44 Filtered = [{K, V} || {K, V} <- Params, not lists:member(K, Remove)],
527 44 cow_qs:qs(Filtered).
528
529 %% ===================================================================
530 %% Filters
531 %% ===================================================================
532
533 check_304(Req, LibVersion) ->
534 63 case get_if_modified(Req) of
535 V when is_integer(V), V >= LibVersion ->
536 4 {304, cowboy_req:reply(304, maybe_backoff(#{
537 <<"last-modified-version">> => integer_to_binary(LibVersion)
538 }), Req)};
539 _ ->
540 59 continue
541 end.
542
543 10 filter_by_keys(Records, all) -> Records;
544 filter_by_keys(Records, Keys) ->
545 2 KeySet = sets:from_list(Keys),
546 2 [R || R <- Records, sets:is_element(record_key(R), KeySet)].
547
548 4 record_key(#shurbej_collection{id = {_, _, K}}) -> K;
549 5 record_key(#shurbej_search{id = {_, _, K}}) -> K;
550
:-(
record_key(#shurbej_item{id = {_, _, K}}) -> K.
551
552 44 filter_by_tag(Items, none) -> Items;
553 filter_by_tag(Items, Tag) ->
554 2 [I || #shurbej_item{data = D} = I <- Items,
555 6 lists:any(fun(T) -> maps:get(<<"tag">>, T, <<>>) =:= Tag end,
556 maps:get(<<"tags">>, D, []))].
557
558 41 filter_by_item_type(Items, none) -> Items;
559 filter_by_item_type(Items, Type) ->
560 5 [I || #shurbej_item{data = D} = I <- Items,
561 219 maps:get(<<"itemType">>, D, <<>>) =:= Type].
562
563 42 filter_by_query(Items, none, _Mode) -> Items;
564 filter_by_query(Items, Query, Mode) ->
565 4 Lower = string:lowercase(Query),
566 4 [I || #shurbej_item{data = D} = I <- Items,
567 164 matches_query(D, Lower, Mode)].
568
569 matches_query(Data, Query, <<"everything">>) ->
570 40 maps:fold(fun(_K, V, Acc) ->
571 167 Acc orelse (is_binary(V) andalso
572 122 binary:match(string:lowercase(V), Query) =/= nomatch)
573 end, false, Data);
574 matches_query(Data, Query, _TitleCreatorYear) ->
575 124 Title = string:lowercase(maps:get(<<"title">>, Data, <<>>)),
576 124 Date = string:lowercase(maps:get(<<"date">>, Data, <<>>)),
577 124 CreatorStr = case maps:get(<<"creators">>, Data, []) of
578 [C | _] ->
579
:-(
Name = maps:get(<<"name">>, C, <<>>),
580
:-(
Last = maps:get(<<"lastName">>, C, <<>>),
581
:-(
First = maps:get(<<"firstName">>, C, <<>>),
582
:-(
string:lowercase(<<Name/binary, " ", Last/binary, " ", First/binary>>);
583 124 _ -> <<>>
584 end,
585 124 binary:match(Title, Query) =/= nomatch orelse
586 122 binary:match(CreatorStr, Query) =/= nomatch orelse
587 122 binary:match(Date, Query) =/= nomatch.
588
589 %% ===================================================================
590 %% JSON responses
591 %% ===================================================================
592
593 json_response(StatusCode, Body, Req) ->
594 130 cowboy_req:reply(StatusCode, maybe_backoff(#{
595 <<"content-type">> => <<"application/json">>,
596 <<"zotero-api-version">> => <<"3">>
597 }), simdjson:encode(Body), Req).
598
599 json_response(StatusCode, Body, Version, Req) ->
600 128 cowboy_req:reply(StatusCode, maybe_backoff(#{
601 <<"content-type">> => <<"application/json">>,
602 <<"last-modified-version">> => integer_to_binary(Version),
603 <<"zotero-api-version">> => <<"3">>
604 }), simdjson:encode(Body), Req).
605
606 error_response(StatusCode, Message, Req) ->
607 83 json_response(StatusCode, #{<<"message">> => Message}, Req).
608
609 auth_error_response({rate_limited, RetryAfter}, Req) ->
610 2 cowboy_req:reply(429, #{
611 <<"content-type">> => <<"application/json">>,
612 <<"retry-after">> => integer_to_binary(RetryAfter),
613 <<"zotero-api-version">> => <<"3">>
614 }, simdjson:encode(#{<<"message">> => <<"Rate limit exceeded. Try again later.">>}), Req);
615 auth_error_response(rate_limited, Req) ->
616
:-(
auth_error_response({rate_limited, 60}, Req);
617 auth_error_response(_, Req) ->
618 10 error_response(403, <<"Forbidden">>, Req).
619
620 %% Inject Backoff header if the rate limiter stashed a hint during authorize/1.
621 maybe_backoff(Headers) ->
622 327 case erase(shurbej_backoff) of
623 325 undefined -> Headers;
624 Secs when is_integer(Secs), Secs > 0 ->
625 2 Headers#{<<"backoff">> => integer_to_binary(Secs)};
626
:-(
_ -> Headers
627 end.
628
629 %% ===================================================================
630 %% Envelope helpers — wrap raw data in Zotero API format
631 %% ===================================================================
632
633 base_url() ->
634 539 to_binary(application:get_env(shurbej, base_url, <<"http://localhost:8080">>)).
635
636 library_obj({user, Id}) ->
637 533 #{<<"type">> => <<"user">>, <<"id">> => Id};
638 library_obj({group, Id}) ->
639 3 #{<<"type">> => <<"group">>, <<"id">> => Id}.
640
641 envelope_item(LibRef, Item) ->
642
:-(
envelope_item(LibRef, Item, #{}).
643
644 envelope_item(LibRef, #shurbej_item{id = {_, _, Key}, version = Version, data = Data},
645 ChildrenCounts) ->
646 446 Base = base_url(),
647 446 Prefix = lib_path_prefix(LibRef),
648 446 NumChildren = maps:get(Key, ChildrenCounts, 0),
649 446 #{
650 <<"key">> => Key,
651 <<"version">> => Version,
652 <<"library">> => library_obj(LibRef),
653 <<"links">> => #{
654 <<"self">> => #{
655 <<"href">> => <<Base/binary, Prefix/binary, "/items/", Key/binary>>,
656 <<"type">> => <<"application/json">>
657 },
658 <<"alternate">> => #{
659 <<"href">> => <<Base/binary, Prefix/binary, "/items/", Key/binary>>,
660 <<"type">> => <<"text/html">>
661 }
662 },
663 <<"meta">> => #{<<"numChildren">> => NumChildren},
664 <<"data">> => Data#{<<"key">> => Key, <<"version">> => Version}
665 }.
666
667 envelope_collection(LibRef, #shurbej_collection{id = {_, _, Key}, version = Version, data = Data}) ->
668 5 Base = base_url(),
669 5 Prefix = lib_path_prefix(LibRef),
670 5 NumColls = maps:get(<<"numCollections">>, Data, 0),
671 5 #{
672 <<"key">> => Key,
673 <<"version">> => Version,
674 <<"library">> => library_obj(LibRef),
675 <<"links">> => #{
676 <<"self">> => #{
677 <<"href">> => <<Base/binary, Prefix/binary, "/collections/", Key/binary>>,
678 <<"type">> => <<"application/json">>
679 }
680 },
681 <<"meta">> => #{<<"numCollections">> => NumColls, <<"numItems">> => 0},
682 <<"data">> => Data#{<<"key">> => Key, <<"version">> => Version}
683 }.
684
685 envelope_search(LibRef, #shurbej_search{id = {_, _, Key}, version = Version, data = Data}) ->
686 5 Base = base_url(),
687 5 Prefix = lib_path_prefix(LibRef),
688 5 #{
689 <<"key">> => Key,
690 <<"version">> => Version,
691 <<"library">> => library_obj(LibRef),
692 <<"links">> => #{
693 <<"self">> => #{
694 <<"href">> => <<Base/binary, Prefix/binary, "/searches/", Key/binary>>,
695 <<"type">> => <<"application/json">>
696 }
697 },
698 <<"meta">> => #{},
699 <<"data">> => Data#{<<"key">> => Key, <<"version">> => Version}
700 }.
701
702 %% Shared Zotero-compatible envelope for a group record. Used both by the
703 %% single-group endpoint (/groups/:id) and the per-user group listing
704 %% (/users/:id/groups); keeping them unified means the shape can't drift.
705 envelope_group(#shurbej_group{
706 group_id = Id, name = Name, owner_id = Owner, type = Type,
707 description = Desc, url = Url, has_image = HasImage,
708 library_editing = LibEd, library_reading = LibRd, file_editing = FileEd,
709 version = Version}) ->
710 2 Base = base_url(),
711 2 IdBin = integer_to_binary(Id),
712 2 GroupUrl = <<Base/binary, "/groups/", IdBin/binary>>,
713 2 #{
714 <<"id">> => Id,
715 <<"version">> => Version,
716 <<"links">> => #{
717 <<"self">> => #{
718 <<"href">> => GroupUrl,
719 <<"type">> => <<"application/json">>
720 },
721 <<"alternate">> => #{
722 <<"href">> => GroupUrl,
723 <<"type">> => <<"text/html">>
724 }
725 },
726 <<"meta">> => #{
727 <<"created">> => <<>>,
728 <<"lastModified">> => <<>>,
729 <<"numItems">> => 0
730 },
731 <<"data">> => #{
732 <<"id">> => Id,
733 <<"version">> => Version,
734 <<"name">> => Name,
735 <<"owner">> => Owner,
736 <<"type">> => group_type_to_binary(Type),
737 <<"description">> => Desc,
738 <<"url">> => Url,
739 <<"hasImage">> => HasImage,
740 <<"libraryEditing">> => atom_to_binary(LibEd),
741 <<"libraryReading">> => atom_to_binary(LibRd),
742 <<"fileEditing">> => atom_to_binary(FileEd)
743 }
744 }.
745
746 2 group_type_to_binary(private) -> <<"Private">>;
747
:-(
group_type_to_binary(public_closed) -> <<"PublicClosed">>;
748
:-(
group_type_to_binary(public_open) -> <<"PublicOpen">>.
749
750 %% Zlib's gunzip/1 allocates the entire decompressed output — a tiny gzip
751 %% bomb (10KB in, 1GB out) would OOM the handler. Inflate incrementally and
752 %% refuse past MAX_DECOMPRESSED_SIZE.
753 maybe_decompress(Body, Req) ->
754 115 case cowboy_req:header(<<"content-encoding">>, Req) of
755
:-(
<<"gzip">> -> gunzip_bounded(Body, ?MAX_DECOMPRESSED_SIZE);
756 115 _ -> Body
757 end.
758
759 gunzip_bounded(Bin, Max) ->
760
:-(
Z = zlib:open(),
761 %% 31 = gzip auto-detect window
762
:-(
ok = zlib:inflateInit(Z, 31),
763
:-(
try inflate_loop(Z, Bin, Max, [], 0)
764
:-(
catch _:_ -> {error, body_too_large}
765 after
766
:-(
zlib:close(Z)
767 end.
768
769 inflate_loop(Z, In, Max, Acc, Size) ->
770
:-(
case zlib:safeInflate(Z, In) of
771 {continue, Out} ->
772
:-(
NewSize = Size + iolist_size(Out),
773
:-(
case NewSize > Max of
774
:-(
true -> {error, body_too_large};
775
:-(
false -> inflate_loop(Z, <<>>, Max, [Out | Acc], NewSize)
776 end;
777 {finished, Out} ->
778
:-(
NewSize = Size + iolist_size(Out),
779
:-(
case NewSize > Max of
780
:-(
true -> {error, body_too_large};
781
:-(
false -> iolist_to_binary(lists:reverse([Out | Acc]))
782 end
783 end.
784
785
:-(
to_binary(B) when is_binary(B) -> B;
786 539 to_binary(L) when is_list(L) -> list_to_binary(L).
Line Hits Source