aboutsummaryrefslogtreecommitdiff
path: root/server/src/jchat_http.erl
diff options
context:
space:
mode:
authorCalvin Morrison <calvin@pobox.com>2025-09-03 21:15:36 -0400
committerCalvin Morrison <calvin@pobox.com>2025-09-03 21:15:36 -0400
commit49fa5aa2a127bdf8924d02bf77e5086b39c7a447 (patch)
tree61d86a7705dacc9fddccc29fa79d075d83ab8059 /server/src/jchat_http.erl
i vibe coded itHEADmaster
Diffstat (limited to 'server/src/jchat_http.erl')
-rw-r--r--server/src/jchat_http.erl265
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.