diff options
author | Calvin Morrison <calvin@pobox.com> | 2025-09-03 21:15:36 -0400 |
---|---|---|
committer | Calvin Morrison <calvin@pobox.com> | 2025-09-03 21:15:36 -0400 |
commit | 49fa5aa2a127bdf8924d02bf77e5086b39c7a447 (patch) | |
tree | 61d86a7705dacc9fddccc29fa79d075d83ab8059 /server/src/jchat_http.erl |
Diffstat (limited to 'server/src/jchat_http.erl')
-rw-r--r-- | server/src/jchat_http.erl | 265 |
1 files changed, 265 insertions, 0 deletions
diff --git a/server/src/jchat_http.erl b/server/src/jchat_http.erl new file mode 100644 index 0000000..f0d0772 --- /dev/null +++ b/server/src/jchat_http.erl @@ -0,0 +1,265 @@ +-module(jchat_http). + +-export([start_link/0, init/2]). + +start_link() -> + Port = jchat_config:http_port(), + ApiDomain = jchat_config:api_domain(), + WebDomain = jchat_config:web_domain(), + + Dispatch = cowboy_router:compile([ + % API domain routing - NO static files + {ApiDomain, [ + {"/jmap/api", ?MODULE, []}, + {"/jmap/upload/[...]", jchat_http_upload, []}, + {"/jmap/download/[...]", jchat_http_download, []}, + {"/jmap/eventsource", jchat_http_eventsource, []}, + {"/auth/[...]", jchat_http_auth, []}, + {"/_health", jchat_http_health, []}, + {"/[...]", jchat_http_404, []} % 404 for unknown API requests + ]}, + % Web domain routing - ONLY static files + {WebDomain, [ + {"/[...]", jchat_http_static, []} + ]}, + % Fallback for any other domain - redirect to web domain + {'_', [ + {"/_health", jchat_http_health, []}, + {"/[...]", jchat_http_redirect, [{web_domain, WebDomain}]} + ]} + ]), + + {ok, _} = cowboy:start_clear(http, [{port, Port}], #{ + env => #{dispatch => Dispatch} + }), + + {ok, self()}. + +init(Req0, State) -> + Method = cowboy_req:method(Req0), + handle_request(Method, Req0, State). + +handle_request(<<"POST">>, Req0, State) -> + case cowboy_req:read_body(Req0) of + {ok, Body, Req1} -> + process_jmap_request(Body, Req1, State); + {more, _Data, Req1} -> + % Handle large requests + {ok, cowboy_req:reply(413, #{}, <<"Request too large">>, Req1), State} + end; +handle_request(<<"OPTIONS">>, Req0, State) -> + % CORS preflight + Req1 = cowboy_req:reply(200, #{ + <<"access-control-allow-origin">> => <<"*">>, + <<"access-control-allow-methods">> => <<"POST, OPTIONS">>, + <<"access-control-allow-headers">> => <<"content-type, authorization">> + }, <<>>, Req0), + {ok, Req1, State}; +handle_request(_Method, Req0, State) -> + Req1 = cowboy_req:reply(405, #{}, <<"Method Not Allowed">>, Req0), + {ok, Req1, State}. + +process_jmap_request(Body, Req0, State) -> + % Extract Authorization header first + AuthHeader = cowboy_req:header(<<"authorization">>, Req0), + + case jchat_utils:json_decode(Body) of + {ok, RequestData} -> + % Validate Content-Type as per JMAP spec + case validate_content_type(Req0) of + ok -> + case process_jmap_data(RequestData, AuthHeader) of + {ok, ResponseData} -> + JSON = jchat_utils:json_encode(ResponseData), + Req1 = cowboy_req:reply(200, #{ + <<"content-type">> => <<"application/json; charset=utf-8">>, + <<"access-control-allow-origin">> => <<"*">> + }, JSON, Req0), + {ok, Req1, State}; + {error, Error} -> + ErrorResponse = jchat_utils:format_error(Error), + JSON = jchat_utils:json_encode(#{ + <<"type">> => maps:get(type, ErrorResponse), + <<"status">> => maps:get(status, ErrorResponse, 400), + <<"detail">> => maps:get(description, ErrorResponse, <<"Unknown error">>) + }), + Req1 = cowboy_req:reply(400, #{ + <<"content-type">> => <<"application/json; charset=utf-8">>, + <<"access-control-allow-origin">> => <<"*">> + }, JSON, Req0), + {ok, Req1, State} + end; + {error, invalid_content_type} -> + ErrorJSON = jchat_utils:json_encode(#{ + type => <<"urn:ietf:params:jmap:error:notJSON">>, + status => 400, + detail => <<"Content-Type must be application/json">> + }), + Req1 = cowboy_req:reply(400, #{ + <<"content-type">> => <<"application/json; charset=utf-8">>, + <<"access-control-allow-origin">> => <<"*">> + }, ErrorJSON, Req0), + {ok, Req1, State} + end; + {error, invalid_json} -> + ErrorJSON = jchat_utils:json_encode(#{ + type => <<"urn:ietf:params:jmap:error:notJSON">>, + status => 400, + detail => <<"Request is not valid JSON">> + }), + Req1 = cowboy_req:reply(400, #{ + <<"content-type">> => <<"application/json; charset=utf-8">>, + <<"access-control-allow-origin">> => <<"*">> + }, ErrorJSON, Req0), + {ok, Req1, State} + end. + +validate_content_type(Req) -> + case cowboy_req:header(<<"content-type">>, Req) of + <<"application/json", _/binary>> -> ok; + undefined -> ok; % Be lenient for now + _ -> {error, invalid_content_type} + end. + +%% Validate JMAP request structure as per RFC 8620 +validate_jmap_request(#{<<"using">> := Using, <<"methodCalls">> := MethodCalls}) + when is_list(Using), is_list(MethodCalls) -> + case validate_using_array(Using) of + ok -> + validate_method_calls_array(MethodCalls); + Error -> + Error + end; +validate_jmap_request(_) -> + {error, #{ + type => <<"urn:ietf:params:jmap:error:notRequest">>, + status => 400, + detail => <<"Missing required 'using' or 'methodCalls' properties">> + }}. + +validate_using_array([]) -> + {error, #{ + type => <<"urn:ietf:params:jmap:error:notRequest">>, + status => 400, + detail => <<"'using' array cannot be empty">> + }}; +validate_using_array(Using) -> + case lists:all(fun is_binary/1, Using) of + true -> ok; + false -> {error, #{ + type => <<"urn:ietf:params:jmap:error:notRequest">>, + status => 400, + detail => <<"All capability URIs in 'using' must be strings">> + }} + end. + +validate_method_calls_array([]) -> + ok; % Empty method calls is valid +validate_method_calls_array(MethodCalls) -> + case lists:all(fun jchat_utils:validate_method_call/1, MethodCalls) of + true -> + validate_unique_call_ids(MethodCalls); + false -> + {error, #{ + type => <<"urn:ietf:params:jmap:error:notRequest">>, + status => 400, + detail => <<"Invalid method call structure">> + }} + end. + +validate_unique_call_ids(MethodCalls) -> + CallIds = [CallId || [_, _, CallId] <- MethodCalls], + case length(CallIds) =:= length(lists:usort(CallIds)) of + true -> ok; + false -> {error, #{ + type => <<"urn:ietf:params:jmap:error:notRequest">>, + status => 400, + detail => <<"Method call IDs must be unique within request">> + }} + end. + +process_jmap_data(#{<<"using">> := Using, <<"methodCalls">> := MethodCalls} = Request, AuthHeader) -> + % Validate request structure first + case validate_jmap_request(Request) of + ok -> + % Validate capabilities + case validate_capabilities(Using) of + ok -> + % Authenticate the request + case authenticate_jmap_request(AuthHeader) of + {ok, AuthContext} -> + AccountId = maps:get(user_id, AuthContext), + CreatedIds = maps:get(<<"createdIds">>, Request, #{}), + process_method_calls(MethodCalls, AccountId, CreatedIds, []); + {error, Error} -> + Error + end; + {error, _} = Error -> + Error + end; + {error, _} = Error -> + Error + end; +process_jmap_data(_, _AuthHeader) -> + {error, #{ + type => <<"urn:ietf:params:jmap:error:notRequest">>, + status => 400, + detail => <<"Request does not match JMAP Request object schema">> + }}. + +%% Authenticate JMAP API request +authenticate_jmap_request(AuthHeader) -> + case jchat_utils:extract_auth_token(AuthHeader) of + {ok, Token} -> + case jchat_auth:validate_token(Token) of + {ok, AuthContext} -> + {ok, AuthContext}; + {error, Error} -> + {error, Error} + end; + {error, no_auth_header} -> + {error, #{ + type => <<"urn:ietf:params:jmap:error:unauthorized">>, + status => 401, + detail => <<"Authentication required. Please log in or register.">>, + prompt => <<"register">> + }}; + {error, invalid_auth_format} -> + {error, #{ + type => <<"urn:ietf:params:jmap:error:unauthorized">>, + status => 401, + detail => <<"Invalid Authorization header format. Use 'Bearer <token>'">>, + prompt => <<"register">> + }} + end. + +validate_capabilities(Using) -> + SupportedCaps = [ + <<"urn:ietf:params:jmap:core">>, + <<"urn:ietf:params:jmap:chat">> + ], + case lists:all(fun(Cap) -> lists:member(Cap, SupportedCaps) end, Using) of + true -> ok; + false -> {error, #{ + type => <<"urn:ietf:params:jmap:error:unknownCapability">>, + status => 400, + detail => <<"Unknown capability requested">> + }} + end. + +process_method_calls([], _AccountId, CreatedIds, Acc) -> + {ok, #{ + <<"methodResponses">> => lists:reverse(Acc), + <<"createdIds">> => CreatedIds, + <<"sessionState">> => <<"default-state">> + }}; +process_method_calls([[Method, Args, CallId] | Rest], AccountId, CreatedIds, Acc) -> + case jchat_methods:handle_method(Method, Args, AccountId) of + {ok, Response} -> + ResponseCall = [Method, Response, CallId], + process_method_calls(Rest, AccountId, CreatedIds, [ResponseCall | Acc]); + {error, Error} -> + ErrorResponse = jchat_utils:format_error(Error), + ErrorCall = [<<"error">>, ErrorResponse, CallId], + process_method_calls(Rest, AccountId, CreatedIds, [ErrorCall | Acc]) + end. |