| 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). |