/__w/shurbej/shurbej/_build/test/cover/ct/shurbej_http_keys.html

1 -module(shurbej_http_keys).
2 -include_lib("shurbej_store/include/shurbej_records.hrl").
3
4 -export([init/2]).
5
6 init(Req0, #{action := create_key} = State) ->
7 5 case cowboy_req:method(Req0) of
8 5 <<"POST">> -> handle_create_key(Req0, State);
9
:-(
_ -> method_not_allowed(Req0, State)
10 end;
11
12 init(Req0, #{action := current} = State) ->
13 9 case cowboy_req:method(Req0) of
14 8 <<"GET">> -> handle_get_current(Req0, State);
15 1 <<"DELETE">> -> handle_delete_current(Req0, State);
16
:-(
_ -> method_not_allowed(Req0, State)
17 end;
18
19 init(Req0, #{action := sessions} = State) ->
20 6 case cowboy_req:method(Req0) of
21 6 <<"POST">> -> handle_create_session(Req0, State);
22
:-(
_ -> method_not_allowed(Req0, State)
23 end;
24
25 init(Req0, #{action := session} = State) ->
26 7 case cowboy_req:method(Req0) of
27 5 <<"GET">> -> handle_get_session(Req0, State);
28 2 <<"DELETE">> -> handle_cancel_session(Req0, State);
29
:-(
_ -> method_not_allowed(Req0, State)
30 end;
31
32 init(Req0, #{action := by_key} = State) ->
33 8 case cowboy_req:method(Req0) of
34 6 <<"GET">> -> handle_get_by_key(Req0, State);
35 2 <<"DELETE">> -> handle_delete_by_key(Req0, State);
36
:-(
_ -> method_not_allowed(Req0, State)
37 end;
38
39 init(Req0, State) ->
40
:-(
Req = shurbej_http_common:error_response(404, <<"Not found">>, Req0),
41
:-(
{ok, Req, State}.
42
43 %% GET /keys/current — verify API key, return user info
44 handle_get_current(Req0, State) ->
45 8 case shurbej_http_common:extract_api_key(Req0) of
46 undefined ->
47 1 Req = shurbej_http_common:error_response(403, <<"Forbidden">>, Req0),
48 1 {ok, Req, State};
49 Key ->
50 7 case shurbej_auth:key_info(Key) of
51 {ok, #{user_id := UserId, permissions := RawPerms}} ->
52 5 Username = case shurbej_db:get_user_by_id(UserId) of
53 5 {ok, #shurbej_user{username = U}} -> U;
54
:-(
undefined -> <<"unknown">>
55 end,
56 5 Body = #{
57 <<"userID">> => UserId,
58 <<"username">> => Username,
59 <<"displayName">> => Username,
60 <<"access">> => format_access(RawPerms)
61 },
62 5 {ok, Version} = shurbej_version:get({user, UserId}),
63 5 Req = shurbej_http_common:json_response(200, Body, Version, Req0),
64 5 {ok, Req, State};
65 {error, invalid} ->
66 2 Req = shurbej_http_common:error_response(403, <<"Invalid key">>, Req0),
67 2 {ok, Req, State}
68 end
69 end.
70
71 %% DELETE /keys/current — revoke API key
72 handle_delete_current(Req0, State) ->
73 1 case shurbej_http_common:extract_api_key(Req0) of
74 undefined ->
75
:-(
Req = shurbej_http_common:error_response(403, <<"Forbidden">>, Req0),
76
:-(
{ok, Req, State};
77 Key ->
78 1 shurbej_db:delete_key(Key),
79 1 Req = cowboy_req:reply(204, #{}, <<>>, Req0),
80 1 {ok, Req, State}
81 end.
82
83 %% POST /keys/sessions — create a login session
84 handle_create_session(Req0, State) ->
85 6 case shurbej_session:create() of
86 {ok, Token, LoginUrl} ->
87 6 Body = #{
88 <<"sessionToken">> => Token,
89 <<"loginURL">> => LoginUrl
90 },
91 6 Req = shurbej_http_common:json_response(201, Body, Req0),
92 6 {ok, Req, State};
93 {error, too_many} ->
94
:-(
Req = shurbej_http_common:error_response(429,
95 <<"Too many pending sessions. Try again later.">>, Req0),
96
:-(
{ok, Req, State}
97 end.
98
99 %% GET /keys/sessions/:token — poll session status
100 handle_get_session(Req0, State) ->
101 5 Token = cowboy_req:binding(token, Req0),
102 5 case shurbej_session:get(Token) of
103 {ok, #{status := pending}} ->
104 1 Req = shurbej_http_common:json_response(200, #{<<"status">> => <<"pending">>}, Req0),
105 1 {ok, Req, State};
106 {ok, #{status := completed, api_key := ApiKey, user_info := UserInfo}} ->
107 1 #{user_id := UserId, username := Username} = UserInfo,
108 1 DisplayName = maps:get(display_name, UserInfo, Username),
109 1 Body = #{
110 <<"status">> => <<"completed">>,
111 <<"apiKey">> => ApiKey,
112 <<"userID">> => UserId,
113 <<"username">> => Username,
114 <<"displayName">> => DisplayName
115 },
116 1 Req = shurbej_http_common:json_response(200, Body, Req0),
117 1 shurbej_session:delete(Token),
118 1 {ok, Req, State};
119 {error, expired} ->
120 1 Req = shurbej_http_common:json_response(410,
121 #{<<"status">> => <<"expired">>}, Req0),
122 1 {ok, Req, State};
123 {error, not_found} ->
124 2 Req = shurbej_http_common:error_response(404, <<"Session not found">>, Req0),
125 2 {ok, Req, State}
126 end.
127
128 %% DELETE /keys/sessions/:token — cancel session
129 handle_cancel_session(Req0, State) ->
130 2 Token = cowboy_req:binding(token, Req0),
131 2 case shurbej_session:cancel(Token) of
132 ok ->
133 1 Req = cowboy_req:reply(204, #{}, <<>>, Req0),
134 1 {ok, Req, State};
135 {error, not_found} ->
136 1 Req = shurbej_http_common:error_response(404, <<"Session not found">>, Req0),
137 1 {ok, Req, State}
138 end.
139
140 %% GET /keys/:key — verify a specific API key. The caller must present the
141 %% same key (Zotero-Api-Key header) as the one in the URL; introspecting
142 %% another user's key is forbidden.
143 handle_get_by_key(Req0, State) ->
144 6 UrlKey = cowboy_req:binding(key, Req0),
145 6 case authenticated_key_matches(UrlKey, Req0) of
146 {error, Code, Msg} ->
147 4 Req = shurbej_http_common:error_response(Code, Msg, Req0),
148 4 {ok, Req, State};
149 ok ->
150 2 {ok, #{user_id := UserId, permissions := RawPerms}} =
151 shurbej_auth:key_info(UrlKey),
152 2 Username = case shurbej_db:get_user_by_id(UserId) of
153 2 {ok, #shurbej_user{username = U}} -> U;
154
:-(
undefined -> <<"unknown">>
155 end,
156 2 Body = #{
157 <<"key">> => UrlKey,
158 <<"userID">> => UserId,
159 <<"username">> => Username,
160 <<"displayName">> => Username,
161 <<"access">> => format_access(RawPerms)
162 },
163 2 {ok, Version} = shurbej_version:get({user, UserId}),
164 2 Req = shurbej_http_common:json_response(200, Body, Version, Req0),
165 2 {ok, Req, State}
166 end.
167
168 %% DELETE /keys/:key — revoke a specific API key. Same ownership rule as GET.
169 handle_delete_by_key(Req0, State) ->
170 2 UrlKey = cowboy_req:binding(key, Req0),
171 2 case authenticated_key_matches(UrlKey, Req0) of
172 {error, Code, Msg} ->
173 1 Req = shurbej_http_common:error_response(Code, Msg, Req0),
174 1 {ok, Req, State};
175 ok ->
176 1 shurbej_db:delete_key(UrlKey),
177 1 Req = cowboy_req:reply(204, #{}, <<>>, Req0),
178 1 {ok, Req, State}
179 end.
180
181 %% Authorize /keys/:key operations. The caller's presented key must be valid
182 %% and identical to the URL key — introspection/deletion of another user's
183 %% key is refused.
184 authenticated_key_matches(UrlKey, Req) ->
185 8 case shurbej_http_common:extract_api_key(Req) of
186 1 undefined -> {error, 403, <<"Forbidden">>};
187 PresentedKey ->
188 7 case shurbej_auth:key_info(PresentedKey) of
189 1 {error, invalid} -> {error, 403, <<"Invalid key">>};
190 3 {ok, _} when PresentedKey =:= UrlKey -> ok;
191 3 {ok, _} -> {error, 403, <<"Forbidden">>}
192 end
193 end.
194
195 %% POST /keys — create API key with credentials (Zotero-compatible)
196 %% Body: {"username": "...", "password": "...", "name": "...", "access": {...}}
197 %% `access` is optional; shape matches `format_access/1` output. When omitted,
198 %% the key gets full access on the user library + `groups.all` grants.
199 handle_create_key(Req0, State) ->
200 5 case shurbej_http_common:read_json_body(Req0) of
201 {ok, #{<<"username">> := Username, <<"password">> := Password} = Body, Req1} ->
202 4 Name = maps:get(<<"name">>, Body, <<"API Key">>),
203 4 case byte_size(Name) > 255 of
204 true ->
205
:-(
Req = shurbej_http_common:error_response(400,
206 <<"Key name too long">>, Req1),
207
:-(
{ok, Req, State};
208 false ->
209 4 case shurbej_session:check_login_rate(Username) of
210 {error, rate_limited} ->
211
:-(
Req = shurbej_http_common:error_response(429,
212 <<"Too many login attempts. Please wait a few minutes.">>, Req1),
213
:-(
{ok, Req, State};
214 ok ->
215 4 case shurbej_db:authenticate_user(Username, Password) of
216 {ok, UserId} ->
217 3 shurbej_session:record_login_success(Username),
218 3 Perms = parse_access_or_default(
219 maps:get(<<"access">>, Body, undefined)),
220 3 ApiKey = generate_api_key(),
221 3 shurbej_db:create_key(ApiKey, UserId, Perms),
222 3 RespBody = #{
223 <<"key">> => ApiKey,
224 <<"userID">> => UserId,
225 <<"username">> => Username,
226 <<"displayName">> => Username,
227 <<"name">> => Name,
228 <<"access">> => format_access(Perms)
229 },
230 3 Req = shurbej_http_common:json_response(201, RespBody, Req1),
231 3 {ok, Req, State};
232 {error, invalid} ->
233 1 Req = shurbej_http_common:error_response(403,
234 <<"Invalid username or password">>, Req1),
235 1 {ok, Req, State}
236 end
237 end
238 end;
239 {ok, _, Req1} ->
240 1 Req = shurbej_http_common:error_response(403,
241 <<"Username and password required">>, Req1),
242 1 {ok, Req, State};
243 {error, _Reason, Req1} ->
244
:-(
Req = shurbej_http_common:error_response(400,
245 <<"Invalid JSON">>, Req1),
246
:-(
{ok, Req, State}
247 end.
248
249 %% Parse the body's "access" field (Zotero shape with binary keys) into the
250 %% canonical internal form. Missing or malformed → full access.
251 parse_access_or_default(undefined) ->
252 2 shurbej_http_common:normalize_perms(undefined);
253 parse_access_or_default(Access) when is_map(Access) ->
254 1 Parsed = #{
255 user => parse_user_access(maps:get(<<"user">>, Access, #{})),
256 groups => parse_groups_access(maps:get(<<"groups">>, Access, #{}))
257 },
258 1 shurbej_http_common:normalize_perms(Parsed);
259 parse_access_or_default(_) ->
260
:-(
shurbej_http_common:normalize_perms(undefined).
261
262 parse_user_access(U) when is_map(U) ->
263 1 #{
264 library => bin_truthy(<<"library">>, U),
265 write => bin_truthy(<<"write">>, U),
266 files => bin_truthy(<<"files">>, U),
267 notes => bin_truthy(<<"notes">>, U)
268 };
269 parse_user_access(_) ->
270
:-(
#{library => false, write => false, files => false, notes => false}.
271
272 parse_groups_access(G) when is_map(G) ->
273 1 maps:fold(fun(K, V, Acc) when is_map(V) ->
274 1 Acc#{K => #{
275 library => bin_truthy(<<"library">>, V),
276 write => bin_truthy(<<"write">>, V)
277 }};
278
:-(
(_, _, Acc) -> Acc
279 end, #{}, G);
280
:-(
parse_groups_access(_) -> #{}.
281
282 bin_truthy(K, M) ->
283 6 case maps:get(K, M, false) of
284 2 true -> true;
285 4 _ -> false
286 end.
287
288 generate_api_key() ->
289 3 binary:encode_hex(crypto:strong_rand_bytes(32), lowercase).
290
291 method_not_allowed(Req0, State) ->
292
:-(
Req = shurbej_http_common:error_response(405, <<"Method not allowed">>, Req0),
293
:-(
{ok, Req, State}.
294
295 %% Render stored permissions in the Zotero-compatible response shape. Stored
296 %% legacy flat forms (or `#{access := all}`) are upgraded first via
297 %% normalize_perms, then rendered.
298 format_access(Perms) ->
299 10 Canon = shurbej_http_common:normalize_perms(Perms),
300 10 #{
301 <<"user">> => format_user_bucket(maps:get(user, Canon, #{})),
302 <<"groups">> => format_groups_bucket(maps:get(groups, Canon, #{}))
303 }.
304
305 format_user_bucket(U) when is_map(U) ->
306 10 #{
307 <<"library">> => maps:get(library, U, false),
308 <<"write">> => maps:get(write, U, false),
309 <<"files">> => maps:get(files, U, false),
310 <<"notes">> => maps:get(notes, U, false)
311 };
312 format_user_bucket(_) ->
313
:-(
#{<<"library">> => false, <<"write">> => false,
314 <<"files">> => false, <<"notes">> => false}.
315
316 format_groups_bucket(G) when is_map(G) ->
317 10 maps:fold(fun(K, V, Acc) ->
318 10 Acc#{format_group_key(K) => #{
319 <<"library">> => maps:get(library, V, false),
320 <<"write">> => maps:get(write, V, false)
321 }}
322 end, #{}, G);
323
:-(
format_groups_bucket(_) -> #{}.
324
325 10 format_group_key(all) -> <<"all">>;
326
:-(
format_group_key(N) when is_integer(N) -> integer_to_binary(N);
327
:-(
format_group_key(B) when is_binary(B) -> B.
Line Hits Source