1 |
|
%% @doc A cowboy handler for graphql listeners. It supports both admin and user |
2 |
|
%% schemas. The `schema_endpoint' config option must be set to decide which |
3 |
|
%% schema to use. |
4 |
|
%% |
5 |
|
%% The graphql request is authorized, processed and then passed for execution. |
6 |
|
%% @end |
7 |
|
-module(mongoose_graphql_handler). |
8 |
|
|
9 |
|
-behaviour(mongoose_http_handler). |
10 |
|
-behavior(cowboy_rest). |
11 |
|
|
12 |
|
%% mongoose_http_handler callbacks |
13 |
|
-export([config_spec/0, |
14 |
|
routes/1]). |
15 |
|
|
16 |
|
%% config processing callbacks |
17 |
|
-export([process_config/1]). |
18 |
|
|
19 |
|
%% Cowboy Handler Interface |
20 |
|
-export([init/2]). |
21 |
|
|
22 |
|
%% REST callbacks |
23 |
|
-export([allowed_methods/2, |
24 |
|
resource_exists/2, |
25 |
|
content_types_provided/2, |
26 |
|
content_types_accepted/2, |
27 |
|
charsets_provided/2, |
28 |
|
is_authorized/2]). |
29 |
|
|
30 |
|
%% Data input/output callbacks |
31 |
|
-export([from_json/2, |
32 |
|
to_json/2, |
33 |
|
to_html/2]). |
34 |
|
|
35 |
|
%% Utilities used by the SSE handler |
36 |
|
-export([check_auth_header/2, |
37 |
|
gather/1]). |
38 |
|
|
39 |
|
-ignore_xref([from_json/2, to_html/2, to_json/2]). |
40 |
|
|
41 |
|
-include("mongoose_config_spec.hrl"). |
42 |
|
|
43 |
|
%% mongoose_http_handler callbacks |
44 |
|
|
45 |
|
-spec config_spec() -> mongoose_config_spec:config_section(). |
46 |
|
config_spec() -> |
47 |
53 |
#section{ |
48 |
|
items = #{<<"username">> => #option{type = binary}, |
49 |
|
<<"password">> => #option{type = binary}, |
50 |
|
<<"schema_endpoint">> => #option{type = atom, |
51 |
|
validate = {enum, [admin, domain_admin, user]}}, |
52 |
|
<<"allowed_categories">> => #list{items = #option{type = binary, |
53 |
|
validate = {enum, allowed_categories()}}, |
54 |
|
validate = unique_non_empty}, |
55 |
|
<<"sse_idle_timeout">> => #option{type = int_or_infinity, |
56 |
|
validate = positive}}, |
57 |
|
defaults = #{<<"sse_idle_timeout">> => 3600000}, % 1h |
58 |
|
format_items = map, |
59 |
|
required = [<<"schema_endpoint">>], |
60 |
|
process = fun ?MODULE:process_config/1}. |
61 |
|
|
62 |
|
process_config(Opts) -> |
63 |
159 |
case maps:is_key(username, Opts) =:= maps:is_key(password, Opts) of |
64 |
|
true -> |
65 |
159 |
Opts; |
66 |
|
false -> |
67 |
:-( |
error(#{what => both_username_and_password_required, opts => Opts}) |
68 |
|
end. |
69 |
|
|
70 |
|
-spec routes(mongoose_http_handler:options()) -> mongoose_http_handler:routes(). |
71 |
|
routes(Opts = #{path := Path}) -> |
72 |
175 |
[{Path, ?MODULE, Opts}, |
73 |
|
{Path ++ "/sse", lasse_handler, #{module => mongoose_graphql_sse_handler, |
74 |
|
init_args => Opts}}]. |
75 |
|
|
76 |
|
%% cowboy_rest callbacks |
77 |
|
|
78 |
|
init(Req, Opts) -> |
79 |
1279 |
IndexLocation = {priv_file, mongooseim, "graphql/wsite/index.html"}, |
80 |
1279 |
{cowboy_rest, |
81 |
|
Req, |
82 |
|
Opts#{index_location => IndexLocation} |
83 |
|
}. |
84 |
|
|
85 |
|
allowed_methods(Req, State) -> |
86 |
1279 |
{[<<"GET">>, <<"POST">>], Req, State}. |
87 |
|
|
88 |
|
content_types_accepted(Req, State) -> |
89 |
1260 |
{[ |
90 |
|
{{<<"application">>, <<"json">>, []}, from_json} |
91 |
|
], Req, State}. |
92 |
|
|
93 |
|
content_types_provided(Req, State) -> |
94 |
1263 |
{[ |
95 |
|
{{<<"application">>, <<"json">>, []}, to_json}, |
96 |
|
{{<<"text">>, <<"html">>, []}, to_html} |
97 |
|
], Req, State}. |
98 |
|
|
99 |
|
charsets_provided(Req, State) -> |
100 |
1263 |
{[<<"utf-8">>], Req, State}. |
101 |
|
|
102 |
|
is_authorized(Req, State) -> |
103 |
1279 |
case check_auth_header(Req, State) of |
104 |
|
{ok, State2} -> |
105 |
1263 |
{true, Req, State2}; |
106 |
|
{error, Error} -> |
107 |
16 |
reply_error(Error, Req, State) |
108 |
|
end. |
109 |
|
|
110 |
|
resource_exists(#{method := <<"GET">>} = Req, State) -> |
111 |
3 |
{true, Req, State}; |
112 |
|
resource_exists(#{method := <<"POST">>} = Req, State) -> |
113 |
1260 |
{false, Req, State}. |
114 |
|
|
115 |
|
to_html(Req, #{index_location := {priv_file, App, FileLocation}} = State) -> |
116 |
3 |
Filename = filename:join(code:priv_dir(App), FileLocation), |
117 |
3 |
{ok, Data} = file:read_file(Filename), |
118 |
3 |
{Data, Req, State}. |
119 |
|
|
120 |
|
json_request(Req, State) -> |
121 |
1260 |
case gather(Req) of |
122 |
|
{error, Reason} -> |
123 |
4 |
reply_error(Reason, Req, State); |
124 |
|
{ok, Req2, Decoded} -> |
125 |
1256 |
run_request(Decoded, Req2, State) |
126 |
|
end. |
127 |
|
|
128 |
1260 |
from_json(Req, State) -> json_request(Req, State). |
129 |
:-( |
to_json(Req, State) -> json_request(Req, State). |
130 |
|
|
131 |
|
%% Utils used also by the SSE handler |
132 |
|
|
133 |
|
check_auth_header(Req, State) -> |
134 |
1293 |
try cowboy_req:parse_header(<<"authorization">>, Req) of |
135 |
|
Auth -> |
136 |
1293 |
case check_auth(Auth, State) of |
137 |
|
{ok, State2} -> |
138 |
1275 |
{ok, State2}; |
139 |
|
error -> |
140 |
18 |
{error, make_error(authorize, wrong_credentials)} |
141 |
|
end |
142 |
|
catch |
143 |
|
exit:Err -> |
144 |
:-( |
{error, make_error(authorize, Err)} |
145 |
|
end. |
146 |
|
|
147 |
|
gather(Req, Params) -> |
148 |
1266 |
QueryDocument = document(Params), |
149 |
1266 |
case variables(Params) of |
150 |
|
{ok, Vars} -> |
151 |
1266 |
Operation = operation_name(Params), |
152 |
1266 |
{ok, Req, #{document => QueryDocument, |
153 |
|
vars => Vars, |
154 |
|
operation_name => Operation}}; |
155 |
|
{error, Reason} -> |
156 |
:-( |
{error, Reason} |
157 |
|
end. |
158 |
|
|
159 |
|
%% Internal |
160 |
|
|
161 |
|
check_auth(Auth, #{schema_endpoint := domain_admin} = State) -> |
162 |
465 |
auth_domain_admin(Auth, State); |
163 |
|
check_auth(Auth, #{schema_endpoint := admin} = State) -> |
164 |
576 |
auth_admin(Auth, State); |
165 |
|
check_auth(Auth, #{schema_endpoint := user} = State) -> |
166 |
252 |
auth_user(Auth, State). |
167 |
|
|
168 |
|
auth_user({basic, User, Password}, State) -> |
169 |
246 |
JID = jid:from_binary(User), |
170 |
246 |
case mongoose_api_common:check_password(JID, Password) of |
171 |
245 |
{true, _} -> {ok, State#{authorized => true, |
172 |
|
authorized_as => user, |
173 |
|
schema_ctx => #{user => JID}}}; |
174 |
1 |
_ -> error |
175 |
|
end; |
176 |
|
auth_user(_, State) -> |
177 |
6 |
{ok, State#{authorized => false}}. |
178 |
|
|
179 |
|
auth_admin({basic, Username, Password}, #{username := Username, password := Password} = State) -> |
180 |
566 |
{ok, State#{authorized => true, |
181 |
|
schema_ctx => #{authorized_as => admin} |
182 |
|
}}; |
183 |
|
auth_admin({basic, _, _}, _) -> |
184 |
1 |
error; |
185 |
|
auth_admin(_, #{username := _, password := _} = State) -> |
186 |
9 |
{ok, State#{authorized => false}}; |
187 |
|
auth_admin(_, State) -> |
188 |
|
% auth credentials not provided in config |
189 |
:-( |
{ok, State#{authorized => true, |
190 |
|
schema_ctx => #{authorized_as => admin}}}. |
191 |
|
|
192 |
|
auth_domain_admin({basic, Username, Password}, State) -> |
193 |
461 |
case jid:to_lus(jid:from_binary(Username)) of |
194 |
|
{<<"admin">>, Domain} -> |
195 |
461 |
case mongoose_domain_api:check_domain_password(Domain, Password) of |
196 |
|
ok -> |
197 |
445 |
{ok, State#{authorized => true, |
198 |
|
schema_ctx => #{authorized_as => domain_admin, |
199 |
|
admin => jid:from_binary(Username)}}}; |
200 |
|
{error, _} -> |
201 |
16 |
error |
202 |
|
end; |
203 |
|
_ -> |
204 |
:-( |
error |
205 |
|
end; |
206 |
|
auth_domain_admin(_, State) -> |
207 |
4 |
{ok, State#{authorized => false}}. |
208 |
|
|
209 |
|
run_request(#{document := undefined}, Req, State) -> |
210 |
3 |
reply_error(make_error(decode, no_query_supplied), Req, State); |
211 |
|
run_request(#{} = ReqCtx, Req, #{schema_endpoint := EpName, |
212 |
|
authorized := AuthStatus} = State) -> |
213 |
1253 |
Ep = mongoose_graphql:get_endpoint(EpName), |
214 |
1253 |
Ctx = maps:get(schema_ctx, State, #{}), |
215 |
1253 |
AllowedCategories = maps:get(allowed_categories, State, []), |
216 |
1253 |
ReqCtx2 = ReqCtx#{authorized => AuthStatus, |
217 |
|
ctx => Ctx#{method => http, |
218 |
|
allowed_categories => AllowedCategories}}, |
219 |
1253 |
case mongoose_graphql:execute(Ep, ReqCtx2) of |
220 |
|
{ok, Response} -> |
221 |
992 |
ResponseBody = mongoose_graphql_response:term_to_json(Response), |
222 |
992 |
Req2 = cowboy_req:set_resp_body(ResponseBody, Req), |
223 |
992 |
Reply = cowboy_req:reply(200, Req2), |
224 |
992 |
{stop, Reply, State}; |
225 |
|
{error, Reason} -> |
226 |
261 |
reply_error(Reason, Req, State) |
227 |
|
end. |
228 |
|
|
229 |
|
gather(Req) -> |
230 |
1272 |
case get_params(cowboy_req:method(Req), Req) of |
231 |
6 |
{error, _} = Error -> Error; |
232 |
1266 |
Params -> gather(Req, Params) |
233 |
|
end. |
234 |
|
|
235 |
|
get_params(<<"GET">>, Req) -> |
236 |
12 |
try maps:from_list(cowboy_req:parse_qs(Req)) |
237 |
2 |
catch _:_ -> {error, make_error(decode, invalid_query_parameters)} |
238 |
|
end; |
239 |
|
get_params(<<"POST">>, Req) -> |
240 |
1260 |
{ok, Body, _} = cowboy_req:read_body(Req), |
241 |
1260 |
try jiffy:decode(Body, [return_maps]) |
242 |
4 |
catch _:_ -> {error, make_error(decode, invalid_json_body)} |
243 |
|
end. |
244 |
|
|
245 |
1261 |
document(#{<<"query">> := Q}) -> Q; |
246 |
5 |
document(#{}) -> undefined. |
247 |
|
|
248 |
|
variables(#{<<"variables">> := Vars}) -> |
249 |
1248 |
if |
250 |
|
is_binary(Vars) -> |
251 |
6 |
try jiffy:decode(Vars, [return_maps]) of |
252 |
:-( |
null -> {ok, #{}}; |
253 |
6 |
JSON when is_map(JSON) -> {ok, JSON}; |
254 |
:-( |
_ -> {error, make_error(decode, variables_invalid_json)} |
255 |
|
catch |
256 |
|
_:_ -> |
257 |
:-( |
{error, make_error(decode, variables_invalid_json)} |
258 |
|
end; |
259 |
|
is_map(Vars) -> |
260 |
1242 |
{ok, Vars}; |
261 |
|
Vars == null -> |
262 |
:-( |
{ok, #{}} |
263 |
|
end; |
264 |
|
variables(#{}) -> |
265 |
18 |
{ok, #{}}. |
266 |
|
|
267 |
|
operation_name(#{<<"operationName">> := OpName}) -> |
268 |
1 |
OpName; |
269 |
|
operation_name(#{}) -> |
270 |
1265 |
undefined. |
271 |
|
|
272 |
|
make_error(Phase, Term) -> |
273 |
27 |
#{error_term => Term, phase => Phase}. |
274 |
|
|
275 |
|
reply_error(Msg, Req, State) -> |
276 |
284 |
{Code, Error} = mongoose_graphql_errors:format_error(Msg), |
277 |
284 |
Body = jiffy:encode(#{errors => [Error]}), |
278 |
284 |
Req2 = cowboy_req:set_resp_body(Body, Req), |
279 |
284 |
Reply = cowboy_req:reply(Code, Req2), |
280 |
284 |
{stop, Reply, State}. |
281 |
|
|
282 |
|
allowed_categories() -> |
283 |
53 |
[<<"checkAuth">>, <<"account">>, <<"domain">>, <<"last">>, <<"muc">>, <<"muc_light">>, |
284 |
|
<<"session">>, <<"stanza">>, <<"roster">>, <<"vcard">>, <<"private">>, <<"metric">>, |
285 |
|
<<"stat">>, <<"gdpr">>, <<"mnesia">>, <<"server">>, <<"inbox">>, <<"http_upload">>, |
286 |
|
<<"offline">>, <<"token">>]. |